Compare commits

...

8 Commits
1.3.1 ... 1.4.2

Author SHA1 Message Date
f106244e57 oooops 2025-09-09 12:59:59 -06:00
76a9120184 oops 2025-09-09 12:58:26 -06:00
abeed46c90 1.4.2 - Dropped minSDK down to support Android 12 2025-09-09 12:57:02 -06:00
7770997fd4 1.4.1 - Shortcuts Bug Fix 2025-09-08 00:49:00 -06:00
f45ff8963d Merge remote-tracking branch 'origin/main' 2025-09-06 23:19:36 -06:00
5988cbf1fb 1.4.0 - Shortcuts & Widgets 2025-09-06 23:19:26 -06:00
13654cde70 Update README.md 2025-09-01 07:14:52 +00:00
9064dbe2ef Update README.md 2025-09-01 07:14:40 +00:00
25 changed files with 1371 additions and 540 deletions

3
.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/
@@ -32,4 +33,4 @@ render.experimental.xml
google-services.json google-services.json
# Android Profiling # Android Profiling
*.hprof *.hprof

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

@@ -14,10 +14,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 34 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 20 versionCode = 23
versionName = "1.3.1" versionName = "1.4.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -7,15 +7,15 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- Hardware features --> <!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Permissions for notifications and foreground service --> <!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -36,8 +36,10 @@
<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 -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -48,7 +50,7 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" /> android:resource="@xml/file_provider_paths" />
</provider> </provider>
<!-- Session tracking service --> <!-- Session tracking service -->
<service <service
android:name=".service.SessionTrackingService" android:name=".service.SessionTrackingService"
@@ -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,32 +1,26 @@
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
} }
// Gym operations // Gym operations
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms() fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
@@ -34,7 +28,7 @@ class ClimbRepository(
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query) fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
// Problem operations // Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems() fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
@@ -43,27 +37,36 @@ class ClimbRepository(
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query) fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
// 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 {
@@ -72,47 +75,58 @@ class ClimbRepository(
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// 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}")
} }
} }
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try { try {
// Collect all data with proper error handling // Collect all data with proper error handling
@@ -120,72 +134,81 @@ class ClimbRepository(
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// 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}")
} }
} }
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
// Validate the ZIP file // Validate the ZIP file
if (!file.exists() || file.length() == 0L) { if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist") throw Exception("Invalid ZIP file: file is empty or doesn't exist")
} }
// Extract and validate the ZIP contents // Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file) val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content // Validate JSON content
if (importResult.jsonContent.isBlank()) { if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content") throw Exception("Invalid ZIP file: no data.json found or empty content")
} }
// 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)
// Clear existing data to avoid conflicts // Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts() attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) // Import gyms first (problems depend on gyms)
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
@@ -194,13 +217,14 @@ class ClimbRepository(
throw Exception("Failed to import gym ${gym.name}: ${e.message}") throw Exception("Failed to import gym ${gym.name}: ${e.message}")
} }
} }
// 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 {
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
@@ -208,7 +232,7 @@ class ClimbRepository(
throw Exception("Failed to import problem ${problem.name}: ${e.message}") throw Exception("Failed to import problem ${problem.name}: ${e.message}")
} }
} }
// Import sessions // Import sessions
importData.sessions.forEach { session -> importData.sessions.forEach { session ->
try { try {
@@ -217,7 +241,7 @@ class ClimbRepository(
throw Exception("Failed to import session: ${e.message}") throw Exception("Failed to import session: ${e.message}")
} }
} }
// Import attempts last (depends on problems and sessions) // Import attempts last (depends on problems and sessions)
importData.attempts.forEach { attempt -> importData.attempts.forEach { attempt ->
try { try {
@@ -226,61 +250,66 @@ 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"
)
} }
} }
private fun validateImportData(importData: ClimbDataExport) { private fun validateImportData(importData: ClimbDataExport) {
if (importData.gyms.isEmpty()) { if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found") throw Exception("Import data is invalid: no gyms found")
} }
if (importData.version.isBlank()) { if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information") throw Exception("Import data is invalid: no version information")
} }
// 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")
} }
} }
suspend fun resetAllData() { suspend fun resetAllData() {
try { try {
// Clear all data from database // Clear all data from database
@@ -288,15 +317,14 @@ class ClimbRepository(
sessionDao.deleteAllSessions() sessionDao.deleteAllSessions()
problemDao.deleteAllProblems() problemDao.deleteAllProblems()
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// 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}")
} }
} }
private fun clearAllImages() { private fun clearAllImages() {
try { try {
// Get the images directory // Get the images directory
@@ -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,260 +24,348 @@ 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()
}
} }
// FAB configuration 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()
}
}
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))
} }
) )
} }
composable<Screen.ProblemDetail> { backStackEntry -> composable<Screen.ProblemDetail> { backStackEntry ->
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))
} }
) )
} }
composable<Screen.GymDetail> { backStackEntry -> composable<Screen.GymDetail> { backStackEntry ->
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() }
) )
} }
composable<Screen.AddEditProblem> { backStackEntry -> composable<Screen.AddEditProblem> { backStackEntry ->
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() }
) )
} }
composable<Screen.AddEditSession> { backStackEntry -> composable<Screen.AddEditSession> { backStackEntry ->
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() }
) )
} }
} }
// 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()
)
}
) )
} }
} }
@@ -288,44 +375,41 @@ fun OpenClimbApp() {
fun OpenClimbBottomNavigation(navController: NavHostController) { fun OpenClimbBottomNavigation(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
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(
icon = { Icon(item.icon, contentDescription = item.label) }, NavigationBarItem(
label = { Text(item.label) }, icon = { Icon(item.icon, contentDescription = item.label) },
selected = isSelected, label = { Text(item.label) },
onClick = { selected = isSelected,
navController.navigate(item.screen) { onClick = {
// Clear the entire back stack and go to the selected tab's root screen navController.navigate(item.screen) {
popUpTo(0) { // Clear the entire back stack and go to the selected tab's root screen
inclusive = true popUpTo(0) { inclusive = true }
} // Avoid multiple copies of the same destination when
// Avoid multiple copies of the same destination when // reselecting the same item
// reselecting the same item launchSingleTop = true
launchSingleTop = true // Don't restore state - always start fresh when switching tabs
// Don't restore state - always start fresh when switching tabs restoreState = false
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

@@ -8,346 +8,429 @@ 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
val gyms = repository.getAllGyms().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems = repository.getAllProblems().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions = repository.getAllSessions().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()
)
// Data flows
val gyms =
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems =
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions =
repository
.getAllSessions()
.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)
} }
} }
private suspend fun cleanupOrphanedImages(context: Context) { private suspend fun cleanupOrphanedImages(context: Context) {
val allProblems = repository.getAllProblems().first() val allProblems = repository.getAllProblems().first()
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
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>> = repository.getProblemsByGym(gymId)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
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)
} }
} }
fun getSessionById(id: String): Flow<ClimbSession?> = flow { fun getSessionById(id: String): Flow<ClimbSession?> = flow {
emit(repository.getSessionById(id)) emit(repository.getSessionById(id))
} }
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
} }
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) { if (session != null && session.status == SessionStatus.ACTIVE) {
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)
} }
} }
} }
// 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>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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}"
)
} }
} }
} }
fun importData(file: File) { fun importData(file: File) {
viewModelScope.launch { viewModelScope.launch {
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}"
)
} }
} }
} }
// UI state operations // UI state operations
fun clearMessage() { fun clearMessage() {
_uiState.value = _uiState.value.copy(message = null) _uiState.value = _uiState.value.copy(message = null)
} }
fun clearError() { fun clearError() {
_uiState.value = _uiState.value.copy(error = null) _uiState.value = _uiState.value.copy(error = null)
} }
fun setError(message: String) { fun setError(message: String) {
_uiState.value = _uiState.value.copy(error = message) _uiState.value = _uiState.value.copy(error = message)
} }
fun resetAllData() { fun resetAllData() {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
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 stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
error = "Failed to generate share card: ${e.message}"
)
null
}
} }
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems)
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) {
_uiState.value = _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)
} }
} }
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>
</resources>
<!-- 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>

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>
</resources>
<!-- 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>

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" />