Compare commits

..

8 Commits
1.2.0 ... 1.4.1

30 changed files with 1440 additions and 653 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ local.properties
# Log/OS Files # Log/OS Files
*.log *.log
.DS_Store
# Android Studio generated files and folders # Android Studio generated files and folders
captures/ captures/

View File

@@ -51,6 +51,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2412" /> <option name="screenY" value="2412" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="OPPO" /> <option name="brand" value="OPPO" />

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-27T22:55:36.064836Z"> <DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Pixel_9a.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -6,8 +6,8 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
You have two options: You have two options:
1. Download the latest APK from the Released page 1. Download the latest APK from the Releases page
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a> 2. [<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.openclimb%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FOpenClimb%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22OpenClimb%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22OpenClimb%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
## Requirements ## Requirements

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 18 versionCode = 22
versionName = "1.2.0" versionName = "1.4.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -36,6 +36,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FileProvider for sharing images --> <!-- FileProvider for sharing images -->
@@ -60,6 +62,18 @@
android:name="android.app.foreground_service_type" android:name="android.app.foreground_service_type"
android:value="specialUse" /> android:value="specialUse" />
</service> </service>
<!-- Widget Provider -->
<receiver
android:name=".widget.ClimbStatsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_climb_stats_info" />
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -1,28 +1,52 @@
package com.atridad.openclimb package com.atridad.openclimb
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.atridad.openclimb.ui.OpenClimbApp import com.atridad.openclimb.ui.OpenClimbApp
import com.atridad.openclimb.ui.theme.OpenClimbTheme import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var shortcutAction by mutableStateOf<String?>(null)
private var lastUsedGymId by mutableStateOf<String?>(null)
fun clearShortcutAction() {
shortcutAction = null
lastUsedGymId = null
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb) setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge() enableEdgeToEdge()
shortcutAction = intent?.action
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
setContent { setContent {
OpenClimbTheme { OpenClimbTheme {
Surface( Surface(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize() OpenClimbApp(
) { shortcutAction = shortcutAction,
OpenClimbApp() lastUsedGymId = lastUsedGymId,
onShortcutActionProcessed = { clearShortcutAction() }
)
} }
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
shortcutAction = intent.action
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
}
} }

View File

@@ -1,27 +1,21 @@
package com.atridad.openclimb.data.repository package com.atridad.openclimb.data.repository
import android.content.Context import android.content.Context
import android.os.Environment
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ZipExportImportUtils import com.atridad.openclimb.utils.ZipExportImportUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository( class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
database: OpenClimbDatabase,
private val context: Context
) {
private val gymDao = database.gymDao() private val gymDao = database.gymDao()
private val problemDao = database.problemDao() private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao() private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val json = Json { private val json = Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -47,23 +41,32 @@ class ClimbRepository(
// Session operations // Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = sessionDao.getSessionsByGym(gymId) fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
getGymById(recentSessions.first().gymId)
} else {
null
}
}
// Attempt operations // Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId) fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId) attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
// ZIP Export with images - Single format for reliability // ZIP Export with images - Single format for reliability
suspend fun exportAllDataToZip(directory: File? = null): File { suspend fun exportAllDataToZip(directory: File? = null): File {
try { try {
@@ -76,37 +79,48 @@ class ClimbRepository(
// Validate data integrity before export // Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData =
exportedAt = LocalDateTime.now().toString(), ClimbDataExport(
version = "1.0", exportedAt = LocalDateTime.now().toString(),
gyms = allGyms, version = "1.0",
problems = allProblems, gyms = allGyms,
sessions = allSessions, problems = allProblems,
attempts = allAttempts sessions = allSessions,
) attempts = allAttempts
)
// Collect all referenced image paths and validate they exist // Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath -> val validImagePaths =
try { referencedImagePaths
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath) .filter { imagePath ->
imageFile.exists() && imageFile.length() > 0 try {
} catch (e: Exception) { val imageFile =
false com.atridad.openclimb.utils.ImageUtils.getImageFile(
} context,
}.toSet() imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
// Log any missing images for debugging // Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) { if (missingImages.isNotEmpty()) {
android.util.Log.w("ClimbRepository", "Some referenced images are missing: $missingImages") android.util.Log.w(
"ClimbRepository",
"Some referenced images are missing: $missingImages"
)
} }
return ZipExportImportUtils.createExportZip( return ZipExportImportUtils.createExportZip(
context = context, context = context,
exportData = exportData, exportData = exportData,
referencedImagePaths = validImagePaths, referencedImagePaths = validImagePaths,
directory = directory directory = directory
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
@@ -124,31 +138,39 @@ class ClimbRepository(
// Validate data integrity before export // Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData =
exportedAt = LocalDateTime.now().toString(), ClimbDataExport(
version = "1.0", exportedAt = LocalDateTime.now().toString(),
gyms = allGyms, version = "1.0",
problems = allProblems, gyms = allGyms,
sessions = allSessions, problems = allProblems,
attempts = allAttempts sessions = allSessions,
) attempts = allAttempts
)
// Collect all referenced image paths and validate they exist // Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath -> val validImagePaths =
try { referencedImagePaths
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath) .filter { imagePath ->
imageFile.exists() && imageFile.length() > 0 try {
} catch (e: Exception) { val imageFile =
false com.atridad.openclimb.utils.ImageUtils.getImageFile(
} context,
}.toSet() imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = exportData, exportData = exportData,
referencedImagePaths = validImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Export failed: ${e.message}") throw Exception("Export failed: ${e.message}")
@@ -171,11 +193,12 @@ class ClimbRepository(
} }
// Parse and validate the data structure // Parse and validate the data structure
val importData = try { val importData =
json.decodeFromString<ClimbDataExport>(importResult.jsonContent) try {
} catch (e: Exception) { json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
throw Exception("Invalid data format: ${e.message}") } catch (e: Exception) {
} throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity // Validate data integrity
validateImportData(importData) validateImportData(importData)
@@ -196,10 +219,11 @@ class ClimbRepository(
} }
// Import problems with updated image paths // Import problems with updated image paths
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( val updatedProblems =
importData.problems, ZipExportImportUtils.updateProblemImagePaths(
importResult.importedImagePaths importData.problems,
) importResult.importedImagePaths
)
updatedProblems.forEach { problem -> updatedProblems.forEach { problem ->
try { try {
@@ -226,40 +250,44 @@ class ClimbRepository(
throw Exception("Failed to import attempt: ${e.message}") throw Exception("Failed to import attempt: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Import failed: ${e.message}") throw Exception("Import failed: ${e.message}")
} }
} }
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
attempts: List<Attempt> attempts: List<Attempt>
) { ) {
// Validate that all problems reference valid gyms // Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet() val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds } val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) { if (invalidProblems.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidProblems.size} problems reference non-existent gyms") throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
} }
// Validate that all sessions reference valid gyms // Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds } val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) { if (invalidSessions.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms") throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
} }
// Validate that all attempts reference valid problems and sessions // Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet() val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts = attempts.filter { val invalidAttempts =
it.problemId !in problemIds || it.sessionId !in sessionIds attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
}
if (invalidAttempts.isNotEmpty()) { if (invalidAttempts.isNotEmpty()) {
throw Exception("Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions") throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
} }
} }
@@ -274,9 +302,10 @@ class ClimbRepository(
// Check for reasonable data sizes to prevent malicious imports // Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 || if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 || importData.problems.size > 10000 ||
importData.sessions.size > 10000 || importData.sessions.size > 10000 ||
importData.attempts.size > 100000) { importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file") throw Exception("Import data is too large: possible corruption or malicious file")
} }
} }
@@ -291,7 +320,6 @@ class ClimbRepository(
// Clear all images from storage // Clear all images from storage
clearAllImages() clearAllImages()
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Reset failed: ${e.message}") throw Exception("Reset failed: ${e.message}")
} }
@@ -314,10 +342,10 @@ class ClimbRepository(
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class ClimbDataExport( data class ClimbDataExport(
val exportedAt: String, val exportedAt: String,
val version: String = "1.0", val version: String = "1.0",
val gyms: List<Gym>, val gyms: List<Gym>,
val problems: List<Problem>, val problems: List<Problem>,
val sessions: List<ClimbSession>, val sessions: List<ClimbSession>,
val attempts: List<Attempt> val attempts: List<Attempt>
) )

View File

@@ -4,36 +4,33 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
data class BottomNavigationItem( data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
val screen: Screen,
val icon: ImageVector,
val label: String
)
val bottomNavigationItems = listOf( val bottomNavigationItems =
BottomNavigationItem( listOf(
screen = Screen.Sessions, BottomNavigationItem(
icon = Icons.Default.PlayArrow, screen = Screen.Sessions,
label = "Sessions" icon = Icons.Default.PlayArrow,
), label = "Sessions"
BottomNavigationItem( ),
screen = Screen.Problems, BottomNavigationItem(
icon = Icons.Default.Star, screen = Screen.Problems,
label = "Problems" icon = Icons.Default.Star,
), label = "Problems"
BottomNavigationItem( ),
screen = Screen.Analytics, BottomNavigationItem(
icon = Icons.Default.Info, screen = Screen.Analytics,
label = "Analytics" icon = Icons.Default.Info,
), label = "Analytics"
BottomNavigationItem( ),
screen = Screen.Gyms, BottomNavigationItem(
icon = Icons.Default.LocationOn, screen = Screen.Gyms,
label = "Gyms" icon = Icons.Default.LocationOn,
), label = "Gyms"
BottomNavigationItem( ),
screen = Screen.Settings, BottomNavigationItem(
icon = Icons.Default.Settings, screen = Screen.Settings,
label = "Settings" icon = Icons.Default.Settings,
) label = "Settings"
) )
)

View File

@@ -1,7 +1,5 @@
package com.atridad.openclimb.ui package com.atridad.openclimb.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -13,6 +11,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -25,184 +24,271 @@ import com.atridad.openclimb.navigation.Screen
import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.navigation.bottomNavigationItems
import com.atridad.openclimb.ui.components.NotificationPermissionDialog import com.atridad.openclimb.ui.components.NotificationPermissionDialog
import com.atridad.openclimb.ui.screens.* import com.atridad.openclimb.ui.screens.*
import com.atridad.openclimb.ui.theme.OpenClimbTheme
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory import com.atridad.openclimb.ui.viewmodel.ClimbViewModelFactory
import com.atridad.openclimb.utils.AppShortcutManager
import com.atridad.openclimb.utils.NotificationPermissionUtils import com.atridad.openclimb.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OpenClimbApp() { fun OpenClimbApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
val viewModel: ClimbViewModel = viewModel( val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
factory = ClimbViewModelFactory(repository)
)
// Notification permission state // Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) } var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher // Permission launcher
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher =
contract = ActivityResultContracts.RequestPermission() rememberLauncherForActivityResult(
) { isGranted: Boolean -> contract = ActivityResultContracts.RequestPermission()
// Handle permission result ) { isGranted: Boolean ->
if (isGranted) { if (!isGranted) {
// Permission granted, continue showNotificationPermissionDialog = false
} else { }
// Permission denied, show dialog again later }
showNotificationPermissionDialog = false
}
}
// Check notification permission on first launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) { if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) { !NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} }
} }
} }
// Ensure session tracking service is running when app resumes LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
LaunchedEffect(Unit) {
viewModel.ensureSessionTrackingServiceRunning(context) val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState()
// Update last used gym when gyms change
LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym()
}
}
LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts(
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
)
}
LaunchedEffect(shortcutAction) {
when (shortcutAction) {
AppShortcutManager.ACTION_START_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
activeSession?.let { session -> viewModel.endSession(context, session.id) }
}
}
}
// Process shortcut actions after data is loaded
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"OpenClimbApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"OpenClimbApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id)
} else {
// Try to get the last used gym from the intent or fallback to state
val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
if (targetGym != null) {
android.util.Log.d(
"OpenClimbApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"OpenClimbApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"OpenClimbApp",
"Active session already exists: ${activeSession?.id}"
)
}
// Clear the shortcut action after processing to prevent repeated execution
onShortcutActionProcessed()
}
} }
// FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold( Scaffold(
bottomBar = { bottomBar = { OpenClimbBottomNavigation(navController = navController) },
OpenClimbBottomNavigation(navController = navController) floatingActionButton = {
}, fabConfig?.let { config ->
floatingActionButton = { FloatingActionButton(
fabConfig?.let { config -> onClick = config.onClick,
FloatingActionButton( containerColor = MaterialTheme.colorScheme.primary
onClick = config.onClick, ) {
containerColor = MaterialTheme.colorScheme.primary Icon(
) { imageVector = config.icon,
Icon( contentDescription = config.contentDescription
imageVector = config.icon, )
contentDescription = config.contentDescription }
)
} }
} }
}
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Sessions, startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
// Main screens
composable<Screen.Sessions> { composable<Screen.Sessions> {
val gyms by viewModel.gyms.collectAsState()
val activeSession by viewModel.activeSession.collectAsState()
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) { fabConfig =
FabConfig( if (gyms.isNotEmpty() && activeSession == null) {
icon = Icons.Default.PlayArrow, FabConfig(
contentDescription = "Start Session", icon = Icons.Default.PlayArrow,
onClick = { contentDescription = "Start Session",
// Check notification permission before starting session onClick = {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) { .shouldRequestNotificationPermission() &&
showNotificationPermissionDialog = true !NotificationPermissionUtils
} else { .isNotificationPermissionGranted(
if (gyms.size == 1) { context
viewModel.startSession(context, gyms.first().id) )
} else { ) {
navController.navigate(Screen.AddEditSession()) showNotificationPermissionDialog = true
} } else {
} if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
// Always show gym selection for FAB when
// multiple gyms
navController.navigate(Screen.AddEditSession())
}
}
}
)
} else {
null
} }
)
} else {
null
}
} }
SessionsScreen( SessionsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
} }
) )
} }
composable<Screen.Problems> { composable<Screen.Problems> {
val gyms by viewModel.gyms.collectAsState()
LaunchedEffect(gyms) { LaunchedEffect(gyms) {
fabConfig = if (gyms.isNotEmpty()) { fabConfig =
FabConfig( if (gyms.isNotEmpty()) {
icon = Icons.Default.Add, FabConfig(
contentDescription = "Add Problem", icon = Icons.Default.Add,
onClick = { contentDescription = "Add Problem",
navController.navigate(Screen.AddEditProblem()) onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
} }
)
} else {
null
}
} }
ProblemsScreen( ProblemsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
composable<Screen.Analytics> { composable<Screen.Analytics> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for analytics
}
AnalyticsScreen(viewModel = viewModel) AnalyticsScreen(viewModel = viewModel)
} }
composable<Screen.Gyms> { composable<Screen.Gyms> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fabConfig = FabConfig( fabConfig =
icon = Icons.Default.Add, FabConfig(
contentDescription = "Add Gym", icon = Icons.Default.Add,
onClick = { contentDescription = "Add Gym",
navController.navigate(Screen.AddEditGym()) onClick = { navController.navigate(Screen.AddEditGym()) }
} )
)
} }
GymsScreen( GymsScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToGymDetail = { gymId -> onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId)) navController.navigate(Screen.GymDetail(gymId))
} }
) )
} }
composable<Screen.Settings> { composable<Screen.Settings> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) { fabConfig = null }
fabConfig = null // No FAB for settings
}
SettingsScreen(viewModel = viewModel) SettingsScreen(viewModel = viewModel)
} }
// Detail screens
composable<Screen.SessionDetail> { backStackEntry -> composable<Screen.SessionDetail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.SessionDetail>() val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen( SessionDetailScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -210,12 +296,12 @@ fun OpenClimbApp() {
val args = backStackEntry.toRoute<Screen.ProblemDetail>() val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen( ProblemDetailScreen(
problemId = args.problemId, problemId = args.problemId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId -> onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId)) navController.navigate(Screen.AddEditProblem(problemId = problemId))
} }
) )
} }
@@ -223,29 +309,28 @@ fun OpenClimbApp() {
val args = backStackEntry.toRoute<Screen.GymDetail>() val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen( GymDetailScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId)) navController.navigate(Screen.AddEditGym(gymId = gymId))
}, },
onNavigateToSessionDetail = { sessionId -> onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId)) navController.navigate(Screen.SessionDetail(sessionId))
}, },
onNavigateToProblemDetail = { problemId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
composable<Screen.AddEditGym> { backStackEntry -> composable<Screen.AddEditGym> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditGym>() val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen( AddEditGymScreen(
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -253,10 +338,10 @@ fun OpenClimbApp() {
val args = backStackEntry.toRoute<Screen.AddEditProblem>() val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen( AddEditProblemScreen(
problemId = args.problemId, problemId = args.problemId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
@@ -264,10 +349,10 @@ fun OpenClimbApp() {
val args = backStackEntry.toRoute<Screen.AddEditSession>() val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null } LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen( AddEditSessionScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
gymId = args.gymId, gymId = args.gymId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
} }
@@ -275,10 +360,12 @@ fun OpenClimbApp() {
// Notification permission dialog // Notification permission dialog
if (showNotificationPermissionDialog) { if (showNotificationPermissionDialog) {
NotificationPermissionDialog( NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false }, onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = { onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString()) permissionLauncher.launch(
} NotificationPermissionUtils.getNotificationPermissionString()
)
}
) )
} }
} }
@@ -291,41 +378,38 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
NavigationBar { NavigationBar {
bottomNavigationItems.forEach { item -> bottomNavigationItems.forEach { item ->
val isSelected = when (item.screen) { val isSelected =
is Screen.Sessions -> currentRoute?.contains("Session") == true when (item.screen) {
is Screen.Problems -> currentRoute?.contains("Problem") == true is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true is Screen.Analytics -> currentRoute?.contains("Analytics") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true is Screen.Settings -> currentRoute?.contains("Settings") == true
} else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
NavigationBarItem( NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
// Clear the entire back stack and go to the selected tab's root screen // Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) { popUpTo(0) { inclusive = true }
inclusive = true // Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
} }
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
} }
}
) )
} }
} }
} }
data class FabConfig( data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector, val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String, val contentDescription: String,
val onClick: () -> Unit val onClick: () -> Unit
) )

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -31,12 +32,12 @@ data class ChartDataPoint(
* Configuration for chart styling * Configuration for chart styling
*/ */
data class ChartStyle( data class ChartStyle(
val lineColor: Color = Color(0xFF6366F1), val lineColor: Color,
val fillColor: Color = Color(0x336366F1), val fillColor: Color,
val lineWidth: Float = 3f, val lineWidth: Float = 3f,
val gridColor: Color = Color(0xFFE5E7EB), val gridColor: Color,
val textColor: Color = Color(0xFF374151), val textColor: Color,
val backgroundColor: Color = Color.White val backgroundColor: Color
) )
/** /**
@@ -46,7 +47,13 @@ data class ChartStyle(
fun LineChart( fun LineChart(
data: List<ChartDataPoint>, data: List<ChartDataPoint>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: ChartStyle = ChartStyle(), style: ChartStyle = ChartStyle(
lineColor = MaterialTheme.colorScheme.primary,
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true, showGrid: Boolean = true,
xAxisFormatter: (Float) -> String = { it.toString() }, xAxisFormatter: (Float) -> String = { it.toString() },
yAxisFormatter: (Float) -> String = { it.toString() } yAxisFormatter: (Float) -> String = { it.toString() }
@@ -136,18 +143,27 @@ fun LineChart(
) )
} }
// Draw data points // Draw data points - more pronounced
screenPoints.forEach { point -> screenPoints.forEach { point ->
// Draw outer circle (larger)
drawCircle( drawCircle(
color = style.lineColor, color = style.lineColor,
radius = style.lineWidth * 1.5f, radius = 8f,
center = point center = point
) )
// Draw inner circle (white center)
drawCircle( drawCircle(
color = style.backgroundColor, color = style.backgroundColor,
radius = style.lineWidth * 0.8f, radius = 5f,
center = point center = point
) )
// Draw border for better visibility
drawCircle(
color = style.lineColor,
radius = 8f,
center = point,
style = Stroke(width = 2f)
)
} }
} }
} }
@@ -190,18 +206,7 @@ private fun DrawScope.drawGrid(
strokeWidth = 1.dp.toPx() strokeWidth = 1.dp.toPx()
) )
// Draw label // X-axis labels removed per user request
val text = xAxisFormatter(sessionNum.toFloat())
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft = Offset(
x - textSize.size.width / 2f,
padding + chartHeight + 8.dp.toPx()
)
)
} }
} }

View File

@@ -543,11 +543,18 @@ fun AddEditProblemScreen(
if (selectedDifficultySystem == DifficultySystem.CUSTOM) { if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField( OutlinedTextField(
value = difficultyGrade, value = difficultyGrade,
onValueChange = { difficultyGrade = it }, onValueChange = { newValue ->
// Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }) {
difficultyGrade = newValue
}
},
label = { Text("Grade *") }, label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("Enter custom grade") } placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
supportingText = { Text("Custom grades must be whole numbers") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }

View File

@@ -132,14 +132,12 @@ fun ProgressChartCard(
progressData: List<ProgressDataPoint>, progressData: List<ProgressDataPoint>,
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<com.atridad.openclimb.data.model.Problem>,
) { ) {
// Find all grading systems that have been used // Find all grading systems that have been used in the progress data
val usedSystems = remember(problems) { val usedSystems = remember(progressData) {
problems.map { it.difficulty.system }.distinct().filter { system -> progressData.map { it.difficultySystem }.distinct()
problems.any { it.difficulty.system == system }
}
} }
var selectedSystem by remember { var selectedSystem by remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
} }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -456,7 +454,10 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
else -> 0 else -> 0
} }
} }
DifficultySystem.CUSTOM -> 0 DifficultySystem.CUSTOM -> {
// Custom grades are numeric strings, so parse them directly
grade.toIntOrNull() ?: 0
}
} }
} }

View File

@@ -3,6 +3,7 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -25,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -35,7 +37,6 @@ import com.atridad.openclimb.ui.theme.CustomIcons
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -292,7 +293,7 @@ fun SessionDetailScreen(
// Show stop icon for active sessions, delete icon for completed sessions // Show stop icon for active sessions, delete icon for completed sessions
if (session?.status == SessionStatus.ACTIVE) { if (session?.status == SessionStatus.ACTIVE) {
IconButton(onClick = { IconButton(onClick = {
session?.let { s -> session.let { s ->
viewModel.endSession(context, s.id) viewModel.endSession(context, s.id)
onNavigateBack() onNavigateBack()
} }
@@ -916,11 +917,6 @@ fun GymDetailScreen(
problems.any { problem -> problem.id == attempt.problemId } problems.any { problem -> problem.id == attempt.problemId }
} }
val successfulAttempts =
gymAttempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size
val totalSessions = sessions.size val totalSessions = sessions.size
val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE } val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE }
@@ -1556,76 +1552,6 @@ private fun formatDate(dateString: String): String {
} }
} }
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
// Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) ->
when (system) {
DifficultySystem.V_SCALE -> {
val gradeValues = systemProblems.mapNotNull { problem ->
when {
problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
}
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg")
}
}
DifficultySystem.FONT -> {
val gradeValues = systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt()
averages.add("$avg")
}
}
DifficultySystem.YDS -> {
val gradeValues = systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade
if (grade.startsWith("5.")) {
grade.substring(2).toDoubleOrNull()
} else null
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}")
}
}
DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values
val gradeValues = systemProblems.mapNotNull { problem ->
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average()
averages.add(String.format("%.1f", avg))
}
}
}
}
return if (averages.isNotEmpty()) {
if (averages.size == 1) {
averages.first()
} else {
averages.joinToString(" / ")
}
} else null
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EnhancedAddAttemptDialog( fun EnhancedAddAttemptDialog(
@@ -1835,8 +1761,12 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
TextButton(onClick = { showCreateProblem = false }) { IconButton(onClick = { showCreateProblem = false }) {
Text("← Back", color = MaterialTheme.colorScheme.primary) Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
@@ -1930,9 +1860,14 @@ fun EnhancedAddAttemptDialog(
if (selectedDifficultySystem == DifficultySystem.CUSTOM) { if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField( OutlinedTextField(
value = newProblemGrade, value = newProblemGrade,
onValueChange = { newProblemGrade = it }, onValueChange = { newValue ->
// Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }) {
newProblemGrade = newValue
}
},
label = { Text("Grade *") }, label = { Text("Grade *") },
placeholder = { Text("Enter custom grade") }, placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
colors = colors =
@@ -1943,6 +1878,7 @@ fun EnhancedAddAttemptDialog(
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
), ),
isError = newProblemGrade.isBlank(), isError = newProblemGrade.isBlank(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = supportingText =
if (newProblemGrade.isBlank()) { if (newProblemGrade.isBlank()) {
{ {
@@ -1951,7 +1887,14 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
} else null } else {
{
Text(
"Custom grades must be whole numbers",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }

View File

@@ -8,100 +8,134 @@ import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.service.SessionTrackingService import com.atridad.openclimb.service.SessionTrackingService
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import com.atridad.openclimb.utils.SessionShareUtils import com.atridad.openclimb.utils.SessionShareUtils
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
class ClimbViewModel( class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
private val repository: ClimbRepository
) : ViewModel() {
// UI State flows // UI State flows
private val _uiState = MutableStateFlow(ClimbUiState()) private val _uiState = MutableStateFlow(ClimbUiState())
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow() val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
// Data flows // Data flows
val gyms = repository.getAllGyms().stateIn( val gyms =
scope = viewModelScope, repository
started = SharingStarted.WhileSubscribed(), .getAllGyms()
initialValue = emptyList() .stateIn(
) scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems = repository.getAllProblems().stateIn( val problems =
scope = viewModelScope, repository
started = SharingStarted.WhileSubscribed(), .getAllProblems()
initialValue = emptyList() .stateIn(
) scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions = repository.getAllSessions().stateIn( val sessions =
scope = viewModelScope, repository
started = SharingStarted.WhileSubscribed(), .getAllSessions()
initialValue = emptyList() .stateIn(
) scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
val activeSession = repository.getActiveSessionFlow().stateIn( initialValue = emptyList()
scope = viewModelScope, )
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
val attempts = repository.getAllAttempts().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val activeSession =
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
val attempts =
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
// Gym operations // Gym operations
fun addGym(gym: Gym) { fun addGym(gym: Gym) {
viewModelScope.launch { repository.insertGym(gym) }
}
fun addGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertGym(gym) repository.insertGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateGym(gym: Gym) { fun updateGym(gym: Gym) {
viewModelScope.launch { repository.updateGym(gym) }
}
fun updateGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateGym(gym) repository.updateGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteGym(gym: Gym) { fun deleteGym(gym: Gym) {
viewModelScope.launch { repository.deleteGym(gym) }
}
fun deleteGym(gym: Gym, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteGym(gym) repository.deleteGym(gym)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun getGymById(id: String): Flow<Gym?> = flow { fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
emit(repository.getGymById(id))
}
// Problem operations // Problem operations
fun addProblem(problem: Problem) { fun addProblem(problem: Problem) {
viewModelScope.launch { repository.insertProblem(problem) }
}
fun addProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertProblem(problem) repository.insertProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateProblem(problem: Problem) { fun updateProblem(problem: Problem) {
viewModelScope.launch { repository.updateProblem(problem) }
}
fun updateProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateProblem(problem) repository.updateProblem(problem)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteProblem(problem: Problem, context: Context) { fun deleteProblem(problem: Problem, context: Context) {
viewModelScope.launch { viewModelScope.launch {
// Delete associated images // Delete associated images
problem.imagePaths.forEach { imagePath -> problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
ImageUtils.deleteImage(context, imagePath)
}
repository.deleteProblem(problem) repository.deleteProblem(problem)
// Clean up any remaining orphaned images
cleanupOrphanedImages(context) cleanupOrphanedImages(context)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
@@ -111,29 +145,41 @@ class ClimbViewModel(
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths) ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
} }
fun getProblemById(id: String): Flow<Problem?> = flow { fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
emit(repository.getProblemById(id))
}
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
repository.getProblemsByGym(gymId)
// Session operations // Session operations
fun addSession(session: ClimbSession) { fun addSession(session: ClimbSession) {
viewModelScope.launch { repository.insertSession(session) }
}
fun addSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertSession(session) repository.insertSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateSession(session: ClimbSession) { fun updateSession(session: ClimbSession) {
viewModelScope.launch { repository.updateSession(session) }
}
fun updateSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateSession(session) repository.updateSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteSession(session: ClimbSession) { fun deleteSession(session: ClimbSession) {
viewModelScope.launch { repository.deleteSession(session) }
}
fun deleteSession(session: ClimbSession, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteSession(session) repository.deleteSession(session)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
@@ -142,47 +188,70 @@ class ClimbViewModel(
} }
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId) repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { viewModelScope.launch {
// Check notification permission first android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
_uiState.value = _uiState.value.copy( if (!com.atridad.openclimb.utils.NotificationPermissionUtils
error = "Notification permission is required to track your climbing session. Please enable notifications in settings." .isNotificationPermissionGranted(context)
) ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
)
return@launch return@launch
} }
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
_uiState.value = _uiState.value.copy( android.util.Log.d(
error = "There's already an active session. Please end it first." "ClimbViewModel",
"Active session already exists: ${existingActive.id}"
) )
_uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
return@launch return@launch
} }
android.util.Log.d("ClimbViewModel", "Creating new session")
val newSession = ClimbSession.create(gymId = gymId, notes = notes) val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession) repository.insertSession(newSession)
android.util.Log.d(
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
// Start the tracking service // Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
_uiState.value = _uiState.value.copy( ClimbStatsWidgetProvider.updateAllWidgets(context)
message = "Session started successfully!"
) android.util.Log.d("ClimbViewModel", "Session started successfully")
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
} }
} }
fun endSession(context: Context, sessionId: String) { fun endSession(context: Context, sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
// Check notification permission first if (!com.atridad.openclimb.utils.NotificationPermissionUtils
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) { .isNotificationPermissionGranted(context)
_uiState.value = _uiState.value.copy( ) {
error = "Notification permission is required to manage your climbing session. Please enable notifications in settings." _uiState.value =
) _uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch return@launch
} }
@@ -191,27 +260,22 @@ class ClimbViewModel(
val completedSession = with(ClimbSession) { session.complete() } val completedSession = with(ClimbSession) { session.complete() }
repository.updateSession(completedSession) repository.updateSession(completedSession)
// Stop the tracking service, passing the session id so service can finalize if needed
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId) val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
context.startService(serviceIntent) context.startService(serviceIntent)
_uiState.value = _uiState.value.copy( ClimbStatsWidgetProvider.updateAllWidgets(context)
message = "Session completed!"
) _uiState.value = _uiState.value.copy(message = "Session completed!")
} }
} }
} }
/**
* Check if the session tracking service is running and restart it if needed
*/
fun ensureSessionTrackingServiceRunning(context: Context) { fun ensureSessionTrackingServiceRunning(context: Context) {
viewModelScope.launch { viewModelScope.launch {
val activeSession = repository.getActiveSession() val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) { if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
// Check if service is running by trying to start it again val serviceIntent =
// The service will handle duplicate starts gracefully SessionTrackingService.createStartIntent(context, activeSession.id)
val serviceIntent = SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
} }
} }
@@ -219,45 +283,60 @@ class ClimbViewModel(
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt) {
viewModelScope.launch { repository.insertAttempt(attempt) }
}
fun addAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.insertAttempt(attempt) repository.insertAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun deleteAttempt(attempt: Attempt) { fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch { repository.deleteAttempt(attempt) }
}
fun deleteAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteAttempt(attempt) repository.deleteAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun updateAttempt(attempt: Attempt) { fun updateAttempt(attempt: Attempt) {
viewModelScope.launch { repository.updateAttempt(attempt) }
}
fun updateAttempt(attempt: Attempt, context: Context) {
viewModelScope.launch { viewModelScope.launch {
repository.updateAttempt(attempt) repository.updateAttempt(attempt)
ClimbStatsWidgetProvider.updateAllWidgets(context)
} }
} }
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId) repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId) repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(
message = "Data with images exported successfully" isLoading = false,
) message = "Data with images exported successfully"
)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(
error = "Export failed: ${e.message}" isLoading = false,
) error = "Export failed: ${e.message}"
)
} }
} }
} }
@@ -267,22 +346,25 @@ class ClimbViewModel(
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
// Only support ZIP format for reliability
if (!file.name.lowercase().endsWith(".zip")) { if (!file.name.lowercase().endsWith(".zip")) {
throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.") throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
)
} }
repository.importDataFromZip(file) repository.importDataFromZip(file)
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(
message = "Data imported successfully from ${file.name}" isLoading = false,
) message = "Data imported successfully from ${file.name}"
)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(
error = "Import failed: ${e.message}" isLoading = false,
) error = "Import failed: ${e.message}"
)
} }
} }
} }
@@ -307,39 +389,40 @@ class ClimbViewModel(
repository.resetAllData() repository.resetAllData()
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(
message = "All data has been reset successfully" isLoading = false,
) message = "All data has been reset successfully"
)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value =
isLoading = false, _uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
error = "Reset failed: ${e.message}"
)
} }
} }
} }
// Share operations // Share operations
suspend fun generateSessionShareCard( suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
context: Context, withContext(Dispatchers.IO) {
sessionId: String try {
): File? = withContext(Dispatchers.IO) { val session = repository.getSessionById(sessionId) ?: return@withContext null
try { val attempts = repository.getAttemptsBySession(sessionId).first()
val session = repository.getSessionById(sessionId) ?: return@withContext null val problems =
val attempts = repository.getAttemptsBySession(sessionId).first() repository.getAllProblems().first().filter { problem ->
val problems = repository.getAllProblems().first().filter { problem -> attempts.any { it.problemId == problem.id }
attempts.any { it.problemId == problem.id } }
} val gym = repository.getGymById(session.gymId) ?: return@withContext null
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems) val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats) SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}") _uiState.value =
null _uiState.value.copy(
} error = "Failed to generate share card: ${e.message}"
} )
null
}
}
fun shareSessionCard(context: Context, imageFile: File) { fun shareSessionCard(context: Context, imageFile: File) {
SessionShareUtils.shareSessionCard(context, imageFile) SessionShareUtils.shareSessionCard(context, imageFile)
@@ -347,7 +430,7 @@ class ClimbViewModel(
} }
data class ClimbUiState( data class ClimbUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )

View File

@@ -0,0 +1,110 @@
package com.atridad.openclimb.utils
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
object AppShortcutManager {
const val SHORTCUT_START_SESSION = "start_session"
const val SHORTCUT_END_SESSION = "end_session"
const val ACTION_START_SESSION = "com.atridad.openclimb.action.START_SESSION"
const val ACTION_END_SESSION = "com.atridad.openclimb.action.END_SESSION"
/** Updates the app shortcuts based on current session state */
fun updateShortcuts(
context: Context,
hasActiveSession: Boolean,
hasGyms: Boolean,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
val shortcuts = mutableListOf<ShortcutInfo>()
if (hasActiveSession) {
// Show "End Session" shortcut when there's an active session
shortcuts.add(createEndSessionShortcut(context))
} else if (hasGyms) {
// Show "Start Session" shortcut when no active session but gyms exist
shortcuts.add(createStartSessionShortcut(context, lastUsedGym))
}
shortcutManager.dynamicShortcuts = shortcuts
}
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createStartSessionShortcut(
context: Context,
lastUsedGym: com.atridad.openclimb.data.model.Gym? = null
): ShortcutInfo {
val startIntent =
Intent(context, MainActivity::class.java).apply {
action = ACTION_START_SESSION
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
lastUsedGym?.let { gym -> putExtra("LAST_USED_GYM_ID", gym.id) }
}
val shortLabel =
if (lastUsedGym != null) {
"Start at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_short)
}
val longLabel =
if (lastUsedGym != null) {
"Start a new climbing session at ${lastUsedGym.name}"
} else {
context.getString(R.string.shortcut_start_session_long)
}
return ShortcutInfo.Builder(context, SHORTCUT_START_SESSION)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIcon(Icon.createWithResource(context, R.drawable.ic_play_arrow_24))
.setIntent(startIntent)
.build()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun createEndSessionShortcut(context: Context): ShortcutInfo {
val endIntent =
Intent(context, MainActivity::class.java).apply {
action = ACTION_END_SESSION
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return ShortcutInfo.Builder(context, SHORTCUT_END_SESSION)
.setShortLabel(context.getString(R.string.shortcut_end_session_short))
.setLongLabel(context.getString(R.string.shortcut_end_session_long))
.setIcon(Icon.createWithResource(context, R.drawable.ic_stop_24))
.setIntent(endIntent)
.build()
}
/** Removes all dynamic shortcuts */
fun clearShortcuts(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
shortcutManager.removeAllDynamicShortcuts()
}
}
/** Disables a specific shortcut and shows a disabled message */
fun disableShortcut(context: Context, shortcutId: String, disabledMessage: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
shortcutManager.disableShortcuts(listOf(shortcutId), disabledMessage)
}
}
}

View File

@@ -0,0 +1,150 @@
package com.atridad.openclimb.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ClimbStatsWidgetProvider : AppWidgetProvider() {
private val job = SupervisorJob()
private val coroutineScope = CoroutineScope(Dispatchers.IO + job)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onEnabled(context: Context) {}
override fun onDisabled(context: Context) {
job.cancel()
}
private fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
coroutineScope.launch {
try {
val database = OpenClimbDatabase.getDatabase(context)
val repository = ClimbRepository(database, context)
// Fetch stats data
val sessions = repository.getAllSessions().first()
val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
val activeSession = repository.getActiveSession()
// Calculate stats
val completedSessions = sessions.filter { it.endTime != null }
// Count problems that have been completed (have at least one successful attempt)
val completedProblems =
problems
.filter { problem ->
attempts.any { attempt ->
attempt.problemId == problem.id &&
(attempt.result ==
com.atridad.openclimb.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.openclimb.data.model
.AttemptResult.FLASH)
}
}
.size
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, _) ->
gyms.find { it.id == gymId }?.name
}
?: "No sessions yet"
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(
R.id.widget_total_sessions,
completedSessions.size.toString()
)
views.setTextViewText(
R.id.widget_problems_completed,
completedProblems.toString()
)
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
} catch (e: Exception) {
launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0")
views.setTextViewText(R.id.widget_problems_completed, "0")
views.setTextViewText(R.id.widget_total_problems, "0")
views.setTextViewText(R.id.widget_favorite_gym, "No data")
val intent = Intent(context, MainActivity::class.java)
val pendingIntent =
PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}
}
companion object {
fun updateAllWidgets(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, ClimbStatsWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
val intent =
Intent(context, ClimbStatsWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
context.sendBroadcast(intent)
}
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#81C784"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#EF5350"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4CAF50"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F44336"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_background" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="16dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_outline" />
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_accent" />
<corners android:radius="12dp" />
<stroke
android:width="0.5dp"
android:color="@color/widget_text_primary" />
<padding
android:left="6dp"
android:top="3dp"
android:right="6dp"
android:bottom="3dp" />
</shape>

View File

@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="OpenClimb"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Climbing Stats"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary" />
</LinearLayout>
<!-- Stats Grid -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<!-- Top Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<!-- Sessions Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_sessions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sessions"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Problems Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_problems_completed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Completed"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- Success Rate Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_problems"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_secondary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Problems"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Favorite Gym Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_favorite_gym"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No gyms"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="@color/widget_accent"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorite"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -2,5 +2,14 @@
<resources> <resources>
<!-- Splash background (dark) --> <!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color> <color name="splash_background">#FF121212</color>
</resources>
<!-- Widget colors (dark theme) -->
<color name="widget_background">#FF1E1E1E</color>
<color name="widget_surface">#FF2D2D2D</color>
<color name="widget_outline">#FF404040</color>
<color name="widget_primary">#FF90CAF9</color>
<color name="widget_secondary">#FFA5D6A7</color>
<color name="widget_accent">#FFFF8A65</color>
<color name="widget_text_primary">#FFFFFFFF</color>
<color name="widget_text_secondary">#FFBDBDBD</color>
</resources>

View File

@@ -10,4 +10,14 @@
<!-- Splash background (light) --> <!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color> <color name="splash_background">#FFFFFFFF</color>
<!-- Widget colors (light theme) -->
<color name="widget_background">#FFFFFFFF</color>
<color name="widget_surface">#FFF8F9FA</color>
<color name="widget_outline">#FFE0E0E0</color>
<color name="widget_primary">#FF1976D2</color>
<color name="widget_secondary">#FF388E3C</color>
<color name="widget_accent">#FFFF5722</color>
<color name="widget_text_primary">#FF212121</color>
<color name="widget_text_secondary">#FF757575</color>
</resources> </resources>

View File

@@ -1,4 +1,16 @@
<resources> <resources>
<string name="app_name">OpenClimb</string> <string name="app_name">OpenClimb</string>
<string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string> <string name="session_tracking_service_description">Tracks active climbing sessions and displays session information in the notification area</string>
<!-- App Shortcuts -->
<string name="shortcut_start_session_short">Start Session</string>
<string name="shortcut_start_session_long">Start a new climbing session</string>
<string name="shortcut_start_session_disabled">No gyms available to start session</string>
<string name="shortcut_end_session_short">End Session</string>
<string name="shortcut_end_session_long">End current climbing session</string>
<string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string>
</resources> </resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp"
android:minHeight="180dp"
android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.12.1" agp = "8.12.2"
kotlin = "2.2.10" kotlin = "2.2.10"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"