Compare commits

..

16 Commits
1.0.1 ... 1.4.2

38 changed files with 2543 additions and 1044 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,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

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

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -12,10 +14,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 33 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 15 versionCode = 23
versionName = "1.0.1" versionName = "1.4.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -33,9 +35,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
java { java {
toolchain { toolchain {
@@ -48,6 +47,12 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies { dependencies {
// Core Android libraries // Core Android libraries
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -64,6 +69,7 @@ dependencies {
// Room Database // Room Database
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Navigation // Navigation
@@ -81,6 +87,8 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk) testImplementation(libs.mockk)

View File

@@ -8,11 +8,13 @@
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- Hardware features -->
<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" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -34,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 -->
@@ -52,11 +56,24 @@
android:name=".service.SessionTrackingService" android:name=".service.SessionTrackingService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse"
android:description="@string/session_tracking_service_description">
<meta-data <meta-data
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

@@ -53,6 +53,9 @@ interface AttemptDao {
@Query("SELECT COUNT(*) FROM attempts") @Query("SELECT COUNT(*) FROM attempts")
suspend fun getAttemptsCount(): Int suspend fun getAttemptsCount(): Int
@Query("DELETE FROM attempts")
suspend fun deleteAllAttempts()
@Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId") @Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId")
suspend fun getAttemptsCountBySession(sessionId: String): Int suspend fun getAttemptsCountBySession(sessionId: String): Int

View File

@@ -59,6 +59,9 @@ interface ClimbSessionDao {
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
suspend fun getActiveSession(): ClimbSession? suspend fun getActiveSession(): ClimbSession?
@Query("DELETE FROM climb_sessions")
suspend fun deleteAllSessions()
@Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1")
fun getActiveSessionFlow(): Flow<ClimbSession?> fun getActiveSessionFlow(): Flow<ClimbSession?>
} }

View File

@@ -37,4 +37,7 @@ interface GymDao {
@Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'") @Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'")
fun searchGyms(searchQuery: String): Flow<List<Gym>> fun searchGyms(searchQuery: String): Flow<List<Gym>>
@Query("DELETE FROM gyms")
suspend fun deleteAllGyms()
} }

View File

@@ -59,4 +59,10 @@ interface ProblemDao {
ORDER BY updatedAt DESC ORDER BY updatedAt DESC
""") """)
fun searchProblems(searchQuery: String): Flow<List<Problem>> fun searchProblems(searchQuery: String): Flow<List<Problem>>
@Query("SELECT COUNT(*) FROM problems")
suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems")
suspend fun deleteAllProblems()
} }

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,198 +41,195 @@ 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
// JSON Export
suspend fun exportAllDataToJson(directory: File? = null): File {
val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb")
if (!exportDir.exists()) {
exportDir.mkdirs()
}
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val exportFile = File(exportDir, "openclimb_export_$timestamp.json")
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
val jsonString = json.encodeToString(exportData)
exportFile.writeText(jsonString)
return exportFile
}
suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) {
val gyms = gymDao.getAllGyms().first()
val problems = problemDao.getAllProblems().first()
val sessions = sessionDao.getAllSessions().first()
val attempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
gyms = gyms,
problems = problems,
sessions = sessions,
attempts = attempts
)
val jsonString = json.encodeToString(exportData)
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray())
} ?: throw Exception("Could not open output stream")
}
suspend fun importDataFromJson(file: File) {
try {
val jsonContent = file.readText()
val importData = json.decodeFromString<ClimbDataExport>(jsonContent)
// Import gyms
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (_: Exception) {
// If insertion fails, update instead
gymDao.updateGym(gym)
}
}
// Import problems
importData.problems.forEach { problem ->
try {
problemDao.insertProblem(problem)
} catch (_: Exception) {
problemDao.updateProblem(problem)
}
}
// Import sessions
importData.sessions.forEach { session ->
try {
sessionDao.insertSession(session)
} catch (_: Exception) {
sessionDao.updateSession(session)
}
}
// Import attempts
importData.attempts.forEach { attempt ->
try {
attemptDao.insertAttempt(attempt)
} catch (_: Exception) {
attemptDao.updateAttempt(attempt)
}
}
} catch (e: Exception) {
throw Exception("Failed to import data: ${e.message}")
}
}
// ZIP Export with images
suspend fun exportAllDataToZip(directory: File? = null): File { suspend fun exportAllDataToZip(directory: File? = null): File {
val allGyms = gymDao.getAllGyms().first() try {
val allProblems = problemDao.getAllProblems().first() // Collect all data with proper error handling
val allSessions = sessionDao.getAllSessions().first() val allGyms = gymDao.getAllGyms().first()
val allAttempts = attemptDao.getAllAttempts().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport( // Validate data integrity before export
exportedAt = LocalDateTime.now().toString(), validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
// Collect all referenced image paths val exportData =
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
return ZipExportImportUtils.createExportZip( // Collect all referenced image paths and validate they exist
context = context, val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
exportData = exportData, val validImagePaths =
referencedImagePaths = referencedImagePaths, referencedImagePaths
directory = directory .filter { imagePath ->
) try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
// Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) {
android.util.Log.w(
"ClimbRepository",
"Some referenced images are missing: $missingImages"
)
}
return ZipExportImportUtils.createExportZip(
context = context,
exportData = exportData,
referencedImagePaths = validImagePaths,
directory = directory
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
} }
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
val gyms = gymDao.getAllGyms().first() try {
val problems = problemDao.getAllProblems().first() // Collect all data with proper error handling
val sessions = sessionDao.getAllSessions().first() val allGyms = gymDao.getAllGyms().first()
val attempts = attemptDao.getAllAttempts().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
val exportData = ClimbDataExport( // Validate data integrity before export
exportedAt = LocalDateTime.now().toString(), validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
gyms = gyms,
problems = problems,
sessions = sessions,
attempts = attempts
)
// Collect all image paths val exportData =
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() ClimbDataExport(
exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
ZipExportImportUtils.createExportZipToUri( // Collect all referenced image paths and validate they exist
context = context, val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
uri = uri, val validImagePaths =
exportData = exportData, referencedImagePaths
referencedImagePaths = referencedImagePaths .filter { imagePath ->
) try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = exportData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
} }
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file) val importResult = ZipExportImportUtils.extractImportZip(context, file)
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
// Update problem image paths with the new imported paths // Validate JSON content
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( if (importResult.jsonContent.isBlank()) {
importData.problems, throw Exception("Invalid ZIP file: no data.json found or empty content")
importResult.importedImagePaths }
)
// Import gyms // Parse and validate the data structure
val importData =
try {
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity
validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms)
importData.gyms.forEach { gym -> importData.gyms.forEach { gym ->
try { try {
gymDao.insertGym(gym) gymDao.insertGym(gym)
} catch (e: Exception) { } catch (e: Exception) {
// If insertion fails update instead throw Exception("Failed to import gym ${gym.name}: ${e.message}")
gymDao.updateGym(gym)
} }
} }
// Import problems with updated image paths // Import problems with updated image paths
val updatedProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
updatedProblems.forEach { problem -> updatedProblems.forEach { problem ->
try { try {
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
} catch (e: Exception) { } catch (e: Exception) {
problemDao.updateProblem(problem) throw Exception("Failed to import problem ${problem.name}: ${e.message}")
} }
} }
@@ -247,30 +238,114 @@ class ClimbRepository(
try { try {
sessionDao.insertSession(session) sessionDao.insertSession(session)
} catch (e: Exception) { } catch (e: Exception) {
sessionDao.updateSession(session) throw Exception("Failed to import session: ${e.message}")
} }
} }
// Import attempts // Import attempts last (depends on problems and sessions)
importData.attempts.forEach { attempt -> importData.attempts.forEach { attempt ->
try { try {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(attempt)
} catch (e: Exception) { } catch (e: Exception) {
attemptDao.updateAttempt(attempt) throw Exception("Failed to import attempt: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to import data: ${e.message}") throw Exception("Import failed: ${e.message}")
}
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
}
// Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
}
// Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
}
}
private fun validateImportData(importData: ClimbDataExport) {
if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found")
}
if (importData.version.isBlank()) {
throw Exception("Import data is invalid: no version information")
}
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
}
suspend fun resetAllData() {
try {
// Clear all data from database
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages()
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
private fun clearAllImages() {
try {
// Get the images directory
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
} }
} }
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class ClimbDataExport( data class ClimbDataExport(
val exportedAt: String, val exportedAt: String,
val gyms: List<Gym>, val version: String = "1.0",
val problems: List<Problem>, val gyms: List<Gym>,
val sessions: List<ClimbSession>, val problems: List<Problem>,
val attempts: List<Attempt> val sessions: List<ClimbSession>,
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

@@ -7,7 +7,6 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.openclimb.MainActivity import com.atridad.openclimb.MainActivity
import com.atridad.openclimb.R import com.atridad.openclimb.R
@@ -17,13 +16,13 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null private var notificationJob: Job? = null
private var monitoringJob: Job? = null private var monitoringJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null
private lateinit var repository: ClimbRepository private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
@@ -58,7 +57,6 @@ class SessionTrackingService : Service() {
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel() createNotificationChannel()
acquireWakeLock()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -87,59 +85,51 @@ class SessionTrackingService : Service() {
} }
} }
} }
// Return START_STICKY to restart service if it gets killed
return START_STICKY return START_REDELIVER_INTENT
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
// If the app is removed from recent tasks, ensure the service keeps running
// This helps maintain the notification even if the user swipes away the app
}
override fun onLowMemory() {
super.onLowMemory()
// Don't stop the service on low memory, just log it
// The notification is important for user experience
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
// Cancel any existing jobs
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
// Start the main notification update job try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
notificationJob = serviceScope.launch { notificationJob = serviceScope.launch {
try { try {
// Initial notification update if (!isNotificationActive()) {
updateNotification(sessionId) delay(1000L)
createAndShowNotification(sessionId)
}
// Update every 2 seconds for better performance
while (isActive) { while (isActive) {
delay(2000L) delay(5000L)
updateNotification(sessionId) updateNotification(sessionId)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Log error and continue
e.printStackTrace() e.printStackTrace()
} }
} }
// Start the monitoring job that ensures notification stays active
monitoringJob = serviceScope.launch { monitoringJob = serviceScope.launch {
try { try {
while (isActive) { while (isActive) {
delay(5000L) // Check every 5 seconds delay(10000L)
// Verify the notification is still active
if (!isNotificationActive()) { if (!isNotificationActive()) {
// Notification was dismissed, recreate it
updateNotification(sessionId) updateNotification(sessionId)
} }
// Verify the session is still active
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) { if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking() stopSessionTracking()
@@ -155,7 +145,6 @@ class SessionTrackingService : Service() {
private fun stopSessionTracking() { private fun stopSessionTracking() {
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -171,14 +160,37 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) { private suspend fun updateNotification(sessionId: String) {
try { try {
val session = repository.getSessionById(sessionId) createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) { if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking() stopSessionTracking()
return return
} }
val gym = repository.getGymById(session.gymId) val gym = runBlocking {
val attempts = repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getGymById(session.gymId)
}
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime -> val duration = session.startTime?.let { startTime ->
try { try {
@@ -205,7 +217,7 @@ class SessionTrackingService : Service() {
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
@@ -221,24 +233,13 @@ class SessionTrackingService : Service() {
) )
.build() .build()
// Always start foreground to ensure service stays alive
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
// Also notify separately to ensure it's visible
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
// Don't stop the service on notification errors, just log them throw e
// Try to restart the notification after a delay
try {
delay(5000L)
updateNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
// If retry fails, stop the service to prevent infinite loops
stopSessionTracking()
}
} }
} }
@@ -269,50 +270,23 @@ class SessionTrackingService : Service() {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Session Tracking", "Session Tracking",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_DEFAULT
).apply { ).apply {
description = "Shows active climbing session information" description = "Shows active climbing session information"
setShowBadge(false) setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false) enableLights(false)
enableVibration(false) enableVibration(false)
setSound(null, null)
} }
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
private fun acquireWakeLock() {
try {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"OpenClimb:SessionTrackingWakeLock"
).apply {
acquire(10*60*1000L) // 10 minutes timeout
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun releaseWakeLock() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel() monitoringJob?.cancel()
releaseWakeLock()
serviceScope.cancel() serviceScope.cancel()
} }
} }

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

@@ -0,0 +1,302 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Data point for the line chart
*/
data class ChartDataPoint(
val x: Float,
val y: Float,
val label: String? = null
)
/**
* Configuration for chart styling
*/
data class ChartStyle(
val lineColor: Color,
val fillColor: Color,
val lineWidth: Float = 3f,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/**
* Custom Line Chart with area fill below the line
*/
@Composable
fun LineChart(
data: List<ChartDataPoint>,
modifier: Modifier = Modifier,
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,
xAxisFormatter: (Float) -> String = { it.toString() },
yAxisFormatter: (Float) -> String = { it.toString() }
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Calculate data bounds
val dataMinY = data.minOf { it.y }
val dataMaxY = data.maxOf { it.y }
// Add some padding to Y-axis (10% above and below the data range)
val yPadding = if (dataMaxY == dataMinY) 1f else (dataMaxY - dataMinY) * 0.1f
val minY = dataMinY - yPadding
val maxY = dataMaxY + yPadding
val minX = data.minOf { it.x }
val maxX = data.maxOf { it.x }
val xRange = if (maxX - minX == 0f) 1f else maxX - minX // Minimum range of 1 for single points
val yRange = maxY - minY
// Ensure we have valid ranges
if (yRange == 0f) return@Canvas
// Convert data points to screen coordinates
val screenPoints = data.map { point ->
val x = padding + (point.x - minX) / xRange * chartWidth
val y = padding + chartHeight - (point.y - minY) / yRange * chartHeight
Offset(x, y)
}
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
minX = minX,
maxX = maxX,
minY = minY,
maxY = maxY,
textMeasurer = textMeasurer,
textColor = style.textColor,
xAxisFormatter = xAxisFormatter,
yAxisFormatter = yAxisFormatter,
actualDataPoints = data
)
}
// Draw area fill
if (screenPoints.size > 1) {
drawAreaFill(
points = screenPoints,
padding = padding,
chartHeight = chartHeight,
fillColor = style.fillColor
)
}
// Draw line
if (screenPoints.size > 1) {
drawLine(
points = screenPoints,
lineColor = style.lineColor,
lineWidth = style.lineWidth
)
}
// Draw data points - more pronounced
screenPoints.forEach { point ->
// Draw outer circle (larger)
drawCircle(
color = style.lineColor,
radius = 8f,
center = point
)
// Draw inner circle (white center)
drawCircle(
color = style.backgroundColor,
radius = 5f,
center = point
)
// Draw border for better visibility
drawCircle(
color = style.lineColor,
radius = 8f,
center = point,
style = Stroke(width = 2f)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
minX: Float,
maxX: Float,
minY: Float,
maxY: Float,
textMeasurer: TextMeasurer,
textColor: Color,
xAxisFormatter: (Float) -> String,
yAxisFormatter: (Float) -> String,
actualDataPoints: List<ChartDataPoint>
) {
val textStyle = TextStyle(
color = textColor,
fontSize = 10.sp
)
// Draw vertical grid lines (X-axis) - only at integer values for sessions
val xRange = maxX - minX
if (xRange > 0) {
val startX = kotlin.math.ceil(minX).toInt()
val endX = kotlin.math.floor(maxX).toInt()
for (sessionNum in startX..endX) {
val x = padding + (sessionNum.toFloat() - minX) / xRange * chartWidth
// Draw grid line
drawLine(
color = gridColor,
start = Offset(x, padding),
end = Offset(x, padding + chartHeight),
strokeWidth = 1.dp.toPx()
)
// X-axis labels removed per user request
}
}
// Draw horizontal grid lines (Y-axis) - only at actual data point values
val yRange = maxY - minY
if (yRange > 0) {
// Get unique Y values from actual data points
val actualYValues = actualDataPoints.map { kotlin.math.round(it.y).toInt() }.toSet()
actualYValues.forEach { gradeValue ->
val y = padding + chartHeight - (gradeValue.toFloat() - minY) / yRange * chartHeight
// Only draw if within chart bounds
if (y >= padding && y <= padding + chartHeight) {
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw label
val text = yAxisFormatter(gradeValue.toFloat())
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft = Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}
}
private fun DrawScope.drawAreaFill(
points: List<Offset>,
padding: Float,
chartHeight: Float,
fillColor: Color
) {
val bottomY = padding + chartHeight // This represents the bottom of the chart area
val path = Path().apply {
// Start from bottom-left (at chart bottom level)
moveTo(points.first().x, bottomY)
// Draw to first point
lineTo(points.first().x, points.first().y)
// Draw line through all points
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
// Close the path by going to bottom-right (at chart bottom level) and back to start
lineTo(points.last().x, bottomY)
lineTo(points.first().x, bottomY)
close()
}
drawPath(
path = path,
color = fillColor
)
}
private fun DrawScope.drawLine(
points: List<Offset>,
lineColor: Color,
lineWidth: Float
) {
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
}
drawPath(
path = path,
color = lineColor,
style = Stroke(
width = lineWidth,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}

View File

@@ -16,6 +16,7 @@ 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -80,7 +81,7 @@ fun AddEditGymScreen(
val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
if (isEditing) { if (isEditing) {
viewModel.updateGym(gym.copy(id = gymId)) viewModel.updateGym(gym.copy(id = gymId!!))
} else { } else {
viewModel.addGym(gym) viewModel.addGym(gym)
} }
@@ -348,7 +349,7 @@ fun AddEditProblemScreen(
) )
if (isEditing) { if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId)) viewModel.updateProblem(problem.copy(id = problemId!!))
} else { } else {
viewModel.addProblem(problem) viewModel.addProblem(problem)
} }
@@ -542,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) }
@@ -565,7 +573,7 @@ fun AddEditProblemScreen(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier modifier = Modifier
.menuAnchor() .menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth() .fillMaxWidth()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
@@ -688,6 +696,7 @@ fun AddEditSessionScreen(
) { ) {
val isEditing = sessionId != null val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val context = LocalContext.current
// Session form state // Session form state
var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) } var selectedGym by remember { mutableStateOf<Gym?>(gymId?.let { id -> gyms.find { it.id == id } }) }
@@ -727,15 +736,14 @@ fun AddEditSessionScreen(
TextButton( TextButton(
onClick = { onClick = {
selectedGym?.let { gym -> selectedGym?.let { gym ->
val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
if (isEditing) { if (isEditing) {
viewModel.updateSession(session.copy(id = sessionId)) val session = ClimbSession.create(
gymId = gym.id,
notes = sessionNotes.ifBlank { null }
)
viewModel.updateSession(session.copy(id = sessionId!!))
} else { } else {
viewModel.addSession(session) viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
} }
onNavigateBack() onNavigateBack()
} }

View File

@@ -11,6 +11,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint
import com.atridad.openclimb.ui.components.LineChart
@Composable @Composable
fun AnalyticsScreen( fun AnalyticsScreen(
@@ -57,20 +61,10 @@ fun AnalyticsScreen(
) )
} }
// Success Rate // Progress Chart
item { item {
val successfulAttempts = attempts.count { val progressData = calculateProgressOverTime(sessions, problems, attempts)
it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT") ProgressChartCard(progressData = progressData, problems = problems)
}
val successRate = if (attempts.isNotEmpty()) {
(successfulAttempts.toDouble() / attempts.size * 100).toInt()
} else 0
SuccessRateCard(
successRate = successRate,
successfulAttempts = successfulAttempts,
totalAttempts = attempts.size
)
} }
// Favorite Gym // Favorite Gym
@@ -132,14 +126,22 @@ fun OverallStatsCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SuccessRateCard( fun ProgressChartCard(
successRate: Int, progressData: List<ProgressDataPoint>,
successfulAttempts: Int, problems: List<com.atridad.openclimb.data.model.Problem>,
totalAttempts: Int
) { ) {
// Find all grading systems that have been used in the progress data
val usedSystems = remember(progressData) {
progressData.map { it.difficultySystem }.distinct()
}
var selectedSystem by remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
var expanded by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@@ -148,38 +150,120 @@ fun SuccessRateCard(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
) { ) {
Text(
text = "Success Rate",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "$successRate%", text = "Progress Over Time",
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary modifier = Modifier.weight(1f)
) )
Column(horizontalAlignment = Alignment.End) { // Scale selector dropdown
Text( if (usedSystems.size > 1) {
text = "$successfulAttempts successful", ExposedDropdownMenuBox(
style = MaterialTheme.typography.bodyMedium expanded = expanded,
) onExpandedChange = { expanded = !expanded }
Text( ) {
text = "out of $totalAttempts attempts", OutlinedTextField(
style = MaterialTheme.typography.bodySmall, value = when (selectedSystem) {
color = MaterialTheme.colorScheme.onSurfaceVariant DifficultySystem.V_SCALE -> "V-Scale"
) DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
},
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(when (system) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
})
},
onClick = {
selectedSystem = system
expanded = false
}
)
}
}
}
} }
} }
Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale
val filteredProgressData = remember(progressData, selectedSystem) {
progressData.filter { it.difficultySystem == selectedSystem }
}
if (filteredProgressData.isNotEmpty()) {
val chartData = remember(filteredProgressData) {
// Convert progress data to chart data points ordered by session
filteredProgressData
.sortedBy { it.date }
.mapIndexed { index, p ->
ChartDataPoint(
x = (index + 1).toFloat(),
y = p.maxGradeNumeric.toFloat(),
label = "Session ${index + 1}"
)
}
}
LineChart(
data = chartData,
modifier = Modifier.fillMaxWidth().height(220.dp),
xAxisFormatter = { value ->
"S${value.toInt()}" // S1, S2, S3, etc.
},
yAxisFormatter = { value ->
numericToGrade(selectedSystem, value.toInt())
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "X: session number, Y: max ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}} achieved",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No progress data available for ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}} system",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }
@@ -253,3 +337,207 @@ fun RecentActivityCard(
} }
} }
} }
data class ProgressDataPoint(
val date: String,
val maxGrade: String,
val maxGradeNumeric: Int,
val climbType: ClimbType,
val difficultySystem: DifficultySystem
)
fun calculateProgressOverTime(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList()
}
val sessionProgress = sessions.mapNotNull { session ->
val sessionAttempts = attempts.filter { it.sessionId == session.id }
if (sessionAttempts.isEmpty()) return@mapNotNull null
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in attemptedProblemIds }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem ->
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade)
}
if (highestGradeProblem != null) {
ProgressDataPoint(
date = session.date,
maxGrade = highestGradeProblem.difficulty.grade,
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
climbType = highestGradeProblem.climbType,
difficultySystem = highestGradeProblem.difficulty.system
)
} else null
}
return sessionProgress.sortedBy { it.date }
}
fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
return when (system) {
DifficultySystem.V_SCALE -> {
when (grade) {
"VB" -> 0
else -> grade.removePrefix("V").toIntOrNull() ?: 0
}
}
DifficultySystem.FONT -> {
when (grade) {
"3" -> 3
"4A" -> 4
"4B" -> 5
"4C" -> 6
"5A" -> 7
"5B" -> 8
"5C" -> 9
"6A" -> 10
"6A+" -> 11
"6B" -> 12
"6B+" -> 13
"6C" -> 14
"6C+" -> 15
"7A" -> 16
"7A+" -> 17
"7B" -> 18
"7B+" -> 19
"7C" -> 20
"7C+" -> 21
"8A" -> 22
"8A+" -> 23
"8B" -> 24
"8B+" -> 25
"8C" -> 26
"8C+" -> 27
else -> 0
}
}
DifficultySystem.YDS -> {
when (grade) {
"5.0" -> 50
"5.1" -> 51
"5.2" -> 52
"5.3" -> 53
"5.4" -> 54
"5.5" -> 55
"5.6" -> 56
"5.7" -> 57
"5.8" -> 58
"5.9" -> 59
"5.10a" -> 60
"5.10b" -> 61
"5.10c" -> 62
"5.10d" -> 63
"5.11a" -> 64
"5.11b" -> 65
"5.11c" -> 66
"5.11d" -> 67
"5.12a" -> 68
"5.12b" -> 69
"5.12c" -> 70
"5.12d" -> 71
"5.13a" -> 72
"5.13b" -> 73
"5.13c" -> 74
"5.13d" -> 75
"5.14a" -> 76
"5.14b" -> 77
"5.14c" -> 78
"5.14d" -> 79
"5.15a" -> 80
"5.15b" -> 81
"5.15c" -> 82
"5.15d" -> 83
else -> 0
}
}
DifficultySystem.CUSTOM -> {
// Custom grades are numeric strings, so parse them directly
grade.toIntOrNull() ?: 0
}
}
}
fun numericToGrade(system: DifficultySystem, numeric: Int): String {
return when (system) {
DifficultySystem.V_SCALE -> {
when (numeric) {
0 -> "VB"
else -> "V$numeric"
}
}
DifficultySystem.FONT -> {
when (numeric) {
3 -> "3"
4 -> "4A"
5 -> "4B"
6 -> "4C"
7 -> "5A"
8 -> "5B"
9 -> "5C"
10 -> "6A"
11 -> "6A+"
12 -> "6B"
13 -> "6B+"
14 -> "6C"
15 -> "6C+"
16 -> "7A"
17 -> "7A+"
18 -> "7B"
19 -> "7B+"
20 -> "7C"
21 -> "7C+"
22 -> "8A"
23 -> "8A+"
24 -> "8B"
25 -> "8B+"
26 -> "8C"
27 -> "8C+"
else -> numeric.toString()
}
}
DifficultySystem.YDS -> {
when (numeric) {
50 -> "5.0"
51 -> "5.1"
52 -> "5.2"
53 -> "5.3"
54 -> "5.4"
55 -> "5.5"
56 -> "5.6"
57 -> "5.7"
58 -> "5.8"
59 -> "5.9"
60 -> "5.10a"
61 -> "5.10b"
62 -> "5.10c"
63 -> "5.10d"
64 -> "5.11a"
65 -> "5.11b"
66 -> "5.11c"
67 -> "5.11d"
68 -> "5.12a"
69 -> "5.12b"
70 -> "5.12c"
71 -> "5.12d"
72 -> "5.13a"
73 -> "5.13b"
74 -> "5.13c"
75 -> "5.13d"
76 -> "5.14a"
77 -> "5.14b"
78 -> "5.14c"
79 -> "5.14d"
80 -> "5.15a"
81 -> "5.15b"
82 -> "5.15c"
83 -> "5.15d"
else -> numeric.toString()
}
}
DifficultySystem.CUSTOM -> numeric.toString()
}
}

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()
} }
@@ -426,11 +427,6 @@ fun SessionDetailScreen(
label = "Completed", label = "Completed",
value = completedProblems.size.toString() value = completedProblems.size.toString()
) )
StatItem(
label = "Success Rate",
value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%"
)
} }
// Show grade range(s) with better layout // Show grade range(s) with better layout
@@ -622,10 +618,6 @@ fun ProblemDetailScreen(
// Calculate stats // Calculate stats
val successfulAttempts = val successfulAttempts =
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val successRate =
if (attempts.isNotEmpty()) {
(successfulAttempts.size.toDouble() / attempts.size * 100).toInt()
} else 0
val attemptsWithSessions = val attemptsWithSessions =
attempts attempts
@@ -793,7 +785,6 @@ fun ProblemDetailScreen(
label = "Successful", label = "Successful",
value = successfulAttempts.size.toString() value = successfulAttempts.size.toString()
) )
StatItem(label = "Success Rate", value = "$successRate%")
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -926,14 +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 successRate =
if (gymAttempts.isNotEmpty()) {
(successfulAttempts.size.toDouble() / gymAttempts.size * 100).toInt()
} else 0
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 }
@@ -1042,19 +1025,7 @@ fun GymDetailScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$successRate%",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Success Rate",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -1581,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(
@@ -1860,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
)
} }
} }
@@ -1955,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 =
@@ -1968,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()) {
{ {
@@ -1976,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) }
@@ -2001,7 +1919,7 @@ fun EnhancedAddAttemptDialog(
colors = colors =
ExposedDropdownMenuDefaults ExposedDropdownMenuDefaults
.outlinedTextFieldColors(), .outlinedTextFieldColors(),
modifier = Modifier.menuAnchor().fillMaxWidth(), modifier = Modifier.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true).fillMaxWidth(),
isError = newProblemGrade.isBlank(), isError = newProblemGrade.isBlank(),
supportingText = supportingText =
if (newProblemGrade.isBlank()) { if (newProblemGrade.isBlank()) {

View File

@@ -26,12 +26,16 @@ fun SettingsScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
// State for reset confirmation dialog
var showResetDialog by remember { mutableStateOf(false) }
val packageInfo = remember { val packageInfo = remember {
context.packageManager.getPackageInfo(context.packageName, 0) context.packageManager.getPackageInfo(context.packageName, 0)
} }
val appVersion = packageInfo.versionName val appVersion = packageInfo.versionName
// File picker launcher for import - accepts both ZIP and JSON files // File picker launcher for import - only accepts ZIP files
val importLauncher = rememberLauncherForActivityResult( val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri -> ) { uri ->
@@ -46,9 +50,13 @@ fun SettingsScreen(
} else null } else null
} ?: "import_file" } ?: "import_file"
val extension = fileName.substringAfterLast(".", "") // Only allow ZIP files
val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import" if (!fileName.lowercase().endsWith(".zip")) {
val tempFile = File(context.cacheDir, tempFileName) viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
return@let
}
val tempFile = File(context.cacheDir, "temp_import.zip")
inputStream?.use { input -> inputStream?.use { input ->
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
@@ -62,7 +70,7 @@ fun SettingsScreen(
} }
} }
// File picker launcher for export - save location (ZIP format with images) // File picker launcher for export - ZIP format with images
val exportZipLauncher = rememberLauncherForActivityResult( val exportZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip") contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri -> ) { uri ->
@@ -75,19 +83,6 @@ fun SettingsScreen(
} }
} }
// JSON export launcher
val exportJsonLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let {
try {
viewModel.exportDataToUri(context, uri)
} catch (e: Exception) {
viewModel.setError("Failed to save file: ${e.message}")
}
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -179,19 +174,13 @@ fun SettingsScreen(
) )
) { ) {
ListItem( ListItem(
headlineContent = { Text("Export Data Only") }, headlineContent = { Text("Import Data") },
supportingContent = { Text("Export climbing data to JSON without images") }, supportingContent = { Text("Import climbing data from ZIP file (recommended format)") },
leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, leadingContent = { Icon(Icons.Default.Add, contentDescription = null) },
trailingContent = { trailingContent = {
TextButton( TextButton(
onClick = { onClick = {
val defaultFileName = "openclimb_export_${ importLauncher.launch("application/zip")
java.time.LocalDateTime.now()
.toString()
.replace(":", "-")
.replace(".", "-")
}.json"
exportJsonLauncher.launch(defaultFileName)
}, },
enabled = !uiState.isLoading enabled = !uiState.isLoading
) { ) {
@@ -201,7 +190,7 @@ fun SettingsScreen(
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
Text("Export JSON") Text("Import")
} }
} }
} }
@@ -213,17 +202,17 @@ fun SettingsScreen(
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
) )
) { ) {
ListItem( ListItem(
headlineContent = { Text("Import Data") }, headlineContent = { Text("Reset All Data") },
supportingContent = { Text("Import climbing data from ZIP or JSON file") }, supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") },
leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
trailingContent = { trailingContent = {
TextButton( TextButton(
onClick = { onClick = {
importLauncher.launch("*/*") showResetDialog = true
}, },
enabled = !uiState.isLoading enabled = !uiState.isLoading
) { ) {
@@ -233,7 +222,7 @@ fun SettingsScreen(
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
Text("Import") Text("Reset", color = MaterialTheme.colorScheme.error)
} }
} }
} }
@@ -390,4 +379,51 @@ fun SettingsScreen(
} }
} }
} }
// Reset confirmation dialog
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset All Data") },
text = {
Column {
Text("Are you sure you want to reset all data?")
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This will permanently delete:",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This action cannot be undone. Consider exporting your data first.",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.resetAllData()
showResetDialog = false
}
) {
Text("Reset All Data", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text("Cancel")
}
}
)
}
} }

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,61 +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 exportDataToUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
repository.exportAllDataToUri(context, uri)
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Data exported successfully"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
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}"
)
} }
} }
} }
@@ -283,22 +346,25 @@ class ClimbViewModel(
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value = _uiState.value.copy(isLoading = true)
// Check if it's a ZIP file or JSON file if (!file.name.lowercase().endsWith(".zip")) {
if (file.name.lowercase().endsWith(".zip")) { throw Exception(
repository.importDataFromZip(file) "Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
} else { )
repository.importDataFromJson(file)
} }
_uiState.value = _uiState.value.copy( repository.importDataFromZip(file)
isLoading = false,
message = "Data imported successfully from ${file.name}" _uiState.value =
) _uiState.value.copy(
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}"
)
} }
} }
} }
@@ -316,34 +382,55 @@ class ClimbViewModel(
_uiState.value = _uiState.value.copy(error = message) _uiState.value = _uiState.value.copy(error = message)
} }
// Share operations fun resetAllData() {
suspend fun generateSessionShareCard( viewModelScope.launch {
context: Context, try {
sessionId: String _uiState.value = _uiState.value.copy(isLoading = true)
): File? = withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems = repository.getAllProblems().first().filter { problem ->
attempts.any { it.problemId == problem.id }
}
val gym = repository.getGymById(session.gymId) ?: return@withContext null
val stats = SessionShareUtils.calculateSessionStats(session, attempts, problems) repository.resetAllData()
SessionShareUtils.generateShareCard(context, session, gym, stats)
} catch (e: Exception) { _uiState.value =
_uiState.value = _uiState.value.copy(error = "Failed to generate share card: ${e.message}") _uiState.value.copy(
null isLoading = false,
message = "All data has been reset successfully"
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
}
} }
} }
// Share operations
suspend fun generateSessionShareCard(context: Context, sessionId: String): File? =
withContext(Dispatchers.IO) {
try {
val session = repository.getSessionById(sessionId) ?: return@withContext null
val attempts = repository.getAttemptsBySession(sessionId).first()
val problems =
repository.getAllProblems().first().filter { problem ->
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
}
}
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

@@ -285,11 +285,7 @@ object SessionShareUtils {
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f) drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
} }
// Success rate arc
val successRate = if (stats.totalAttempts > 0) {
(stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
} else 0f
drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint = Paint().apply {
@@ -372,52 +368,7 @@ object SessionShareUtils {
return "${sorted.first().grade} - ${sorted.last().grade}" return "${sorted.first().grade} - ${sorted.last().grade}"
} }
private fun drawSuccessRateArc(
canvas: Canvas,
centerX: Float,
centerY: Float,
successRate: Float,
labelPaint: Paint,
valuePaint: Paint
) {
val radius = 70f
val strokeWidth = 14f
// Background arc
val bgPaint = Paint().apply {
color = "#30FFFFFF".toColorInt()
style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
}
// Success arc
val successPaint = Paint().apply {
color = "#4CAF50".toColorInt()
style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
}
val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
// Draw background arc (full circle)
canvas.drawArc(rect, -90f, 360f, false, bgPaint)
// Draw success arc
val sweepAngle = (successRate / 100f) * 360f
canvas.drawArc(rect, -90f, sweepAngle, false, successPaint)
// Draw percentage text
val percentText = "${successRate.roundToInt()}%"
canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
// Draw label below the arc (outside the ring) for better readability
val belowLabelPaint = Paint(labelPaint)
canvas.drawText("Success Rate", centerX, centerY + radius + 36f, belowLabelPaint)
}
private fun formatSessionDate(dateString: String): String { private fun formatSessionDate(dateString: String): String {
return try { return try {

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

@@ -15,6 +15,7 @@ object ZipExportImportUtils {
private const val DATA_JSON_FILENAME = "data.json" private const val DATA_JSON_FILENAME = "data.json"
private const val IMAGES_DIR_NAME = "images" private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/** /**
* Creates a ZIP file containing the JSON data and all referenced images * Creates a ZIP file containing the JSON data and all referenced images
@@ -38,37 +39,67 @@ object ZipExportImportUtils {
val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-")
val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") val zipFile = File(exportDir, "openclimb_export_$timestamp.zip")
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> try {
// Add JSON data file ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
val json = Json { prettyPrint = true } // Add metadata file first
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) // Add JSON data file
zipOut.putNextEntry(jsonEntry) val json = Json {
zipOut.write(jsonString.toByteArray()) prettyPrint = true
zipOut.closeEntry() ignoreUnknownKeys = true
// Add images
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists()) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
}
} catch (e: Exception) {
// Log error but continue with other images
e.printStackTrace()
} }
} val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
}
return zipFile val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath")
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
}
}
// Log export summary
android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included")
}
// Validate the created ZIP file
if (!zipFile.exists() || zipFile.length() == 0L) {
throw IOException("Failed to create ZIP file: file is empty or doesn't exist")
}
return zipFile
} catch (e: Exception) {
// Clean up failed export
if (zipFile.exists()) {
zipFile.delete()
}
throw IOException("Failed to create export ZIP: ${e.message}")
}
} }
/** /**
@@ -84,37 +115,73 @@ object ZipExportImportUtils {
exportData: com.atridad.openclimb.data.repository.ClimbDataExport, exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) { ) {
context.contentResolver.openOutputStream(uri)?.use { outputStream -> try {
ZipOutputStream(outputStream).use { zipOut -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
// Add JSON data file ZipOutputStream(outputStream).use { zipOut ->
val json = Json { prettyPrint = true } // Add metadata file first
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) // Add JSON data file
zipOut.putNextEntry(jsonEntry) val json = Json {
zipOut.write(jsonString.toByteArray()) prettyPrint = true
zipOut.closeEntry() ignoreUnknownKeys = true
// Add images
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists()) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
}
} catch (e: Exception) {
// Log error but continue with other images
e.printStackTrace()
} }
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry)
zipOut.write(jsonString.toByteArray())
zipOut.closeEntry()
// Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath ->
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry)
FileInputStream(imageFile).use { imageInput ->
imageInput.copyTo(zipOut)
}
zipOut.closeEntry()
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
}
}
android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included")
} }
} } ?: throw IOException("Could not open output stream")
} ?: throw IOException("Could not open output stream")
} catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}")
}
}
private fun createMetadata(
exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("OpenClimb Export Metadata")
appendLine("=======================")
appendLine("Export Date: ${exportData.exportedAt}")
appendLine("Version: ${exportData.version}")
appendLine("Gyms: ${exportData.gyms.size}")
appendLine("Problems: ${exportData.problems.size}")
appendLine("Sessions: ${exportData.sessions.size}")
appendLine("Attempts: ${exportData.attempts.size}")
appendLine("Referenced Images: ${referencedImagePaths.size}")
appendLine("Format: ZIP with embedded JSON data and images")
}
} }
/** /**
@@ -133,50 +200,89 @@ object ZipExportImportUtils {
*/ */
fun extractImportZip(context: Context, zipFile: File): ImportResult { fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = "" var jsonContent = ""
var metadataContent = ""
val importedImagePaths = mutableMapOf<String, String>() val importedImagePaths = mutableMapOf<String, String>()
var foundRequiredFiles = mutableSetOf<String>()
ZipInputStream(FileInputStream(zipFile)).use { zipIn -> try {
var entry = zipIn.nextEntry ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
var entry = zipIn.nextEntry
while (entry != null) { while (entry != null) {
when { when {
entry.name == DATA_JSON_FILENAME -> { entry.name == METADATA_FILENAME -> {
// Read JSON data // Read metadata for validation
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
} foundRequiredFiles.add("metadata")
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}")
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
// Create temporary file to hold the extracted image
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
FileOutputStream(tempFile).use { output ->
zipIn.copyTo(output)
} }
// Import the image to permanent storage entry.name == DATA_JSON_FILENAME -> {
val newPath = ImageUtils.importImageFile(context, tempFile) // Read JSON data
if (newPath != null) { jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
importedImagePaths[originalFilename] = newPath foundRequiredFiles.add("data")
} }
// Clean up temp file entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
tempFile.delete() // Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
try {
// Create temporary file to hold the extracted image
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
FileOutputStream(tempFile).use { output ->
zipIn.copyTo(output)
}
// Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) {
// Import the image to permanent storage
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath")
} else {
android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename")
}
} else {
android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename")
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}")
}
}
else -> {
android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}")
}
} }
zipIn.closeEntry()
entry = zipIn.nextEntry
} }
zipIn.closeEntry()
entry = zipIn.nextEntry
} }
}
if (jsonContent.isEmpty()) { // Validate that we found the required files
throw IOException("No data.json file found in the ZIP archive") if (!foundRequiredFiles.contains("data")) {
} throw IOException("Invalid ZIP file: data.json not found")
}
return ImportResult(jsonContent, importedImagePaths) if (jsonContent.isBlank()) {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed")
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}")
}
} }
/** /**

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,3 +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>
<!-- 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,24 +1,24 @@
[versions] [versions]
agp = "8.12.1" agp = "8.12.2"
kotlin = "2.0.21" kotlin = "2.2.10"
coreKtx = "1.15.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
androidxTestCore = "1.6.0" androidxTestCore = "1.7.0"
androidxTestExt = "1.2.0" androidxTestExt = "1.3.0"
androidxTestRunner = "1.6.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.6.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.2" lifecycleRuntimeKtx = "2.9.3"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.09.00" composeBom = "2025.08.01"
room = "2.6.1" room = "2.7.2"
navigation = "2.8.4" navigation = "2.9.3"
viewmodel = "2.9.2" viewmodel = "2.9.3"
kotlinxSerialization = "1.7.1" kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.9.0" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" coil = "2.7.0"
ksp = "2.0.21-1.0.25" ksp = "2.2.10-2.0.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -64,8 +64,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
# Image Loading # Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Charts - MPAndroidChart for now, will be replaced with Vico when stable
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }