Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7770997fd4
|
|||
|
f45ff8963d
|
|||
|
5988cbf1fb
|
|||
| 13654cde70 | |||
| 9064dbe2ef | |||
|
0537da79e4
|
|||
|
4804049274
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,6 +8,7 @@ local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
@@ -32,4 +33,4 @@ render.experimental.xml
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
*.hprof
|
||||
|
||||
12
.idea/caches/deviceStreaming.xml
generated
12
.idea/caches/deviceStreaming.xml
generated
@@ -51,6 +51,18 @@
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2412" />
|
||||
</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>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OPPO" />
|
||||
|
||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-08-27T22:55:36.064836Z">
|
||||
<DropdownSelection timestamp="2025-09-07T04:49:14.182787Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Pixel_9a.avd" />
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/atridad/.android/avd/Medium_Phone.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -6,8 +6,8 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p
|
||||
|
||||
You have two options:
|
||||
|
||||
1. Download the latest APK from the Released 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>
|
||||
1. Download the latest APK from the Releases page
|
||||
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
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 19
|
||||
versionName = "1.3.0"
|
||||
versionCode = 22
|
||||
versionName = "1.4.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -36,8 +36,10 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
<!-- FileProvider for sharing images -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -48,7 +50,7 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
<!-- Session tracking service -->
|
||||
<service
|
||||
android:name=".service.SessionTrackingService"
|
||||
@@ -60,6 +62,18 @@
|
||||
android:name="android.app.foreground_service_type"
|
||||
android:value="specialUse" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
package com.atridad.openclimb
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.atridad.openclimb.ui.OpenClimbApp
|
||||
import com.atridad.openclimb.ui.theme.OpenClimbTheme
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setTheme(R.style.Theme_OpenClimb)
|
||||
enableEdgeToEdge()
|
||||
|
||||
shortcutAction = intent?.action
|
||||
lastUsedGymId = intent?.getStringExtra("LAST_USED_GYM_ID")
|
||||
|
||||
setContent {
|
||||
OpenClimbTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
OpenClimbApp()
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
OpenClimbApp(
|
||||
shortcutAction = shortcutAction,
|
||||
lastUsedGymId = lastUsedGymId,
|
||||
onShortcutActionProcessed = { clearShortcutAction() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
shortcutAction = intent.action
|
||||
lastUsedGymId = intent.getStringExtra("LAST_USED_GYM_ID")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.model.*
|
||||
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.time.LocalDateTime
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ClimbRepository(
|
||||
database: OpenClimbDatabase,
|
||||
private val context: Context
|
||||
) {
|
||||
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
||||
private val gymDao = database.gymDao()
|
||||
private val problemDao = database.problemDao()
|
||||
private val sessionDao = database.climbSessionDao()
|
||||
private val attemptDao = database.attemptDao()
|
||||
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
|
||||
// Gym operations
|
||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||
@@ -34,7 +28,7 @@ class ClimbRepository(
|
||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
@@ -43,27 +37,36 @@ class ClimbRepository(
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
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()
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(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
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = attemptDao.getAttemptsByProblem(problemId)
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsByProblem(problemId)
|
||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||
|
||||
|
||||
|
||||
// ZIP Export with images - Single format for reliability
|
||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||
try {
|
||||
@@ -72,47 +75,58 @@ class ClimbRepository(
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
val allSessions = sessionDao.getAllSessions().first()
|
||||
val allAttempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
|
||||
// Validate data integrity before export
|
||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
|
||||
val exportData =
|
||||
ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths = referencedImagePaths.filter { imagePath ->
|
||||
try {
|
||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.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")
|
||||
android.util.Log.w(
|
||||
"ClimbRepository",
|
||||
"Some referenced images are missing: $missingImages"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return ZipExportImportUtils.createExportZip(
|
||||
context = context,
|
||||
exportData = exportData,
|
||||
referencedImagePaths = validImagePaths,
|
||||
directory = directory
|
||||
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) {
|
||||
try {
|
||||
// Collect all data with proper error handling
|
||||
@@ -120,72 +134,81 @@ class ClimbRepository(
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
val allSessions = sessionDao.getAllSessions().first()
|
||||
val allAttempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
|
||||
// Validate data integrity before export
|
||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||
|
||||
val exportData = ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
|
||||
val exportData =
|
||||
ClimbDataExport(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "1.0",
|
||||
gyms = allGyms,
|
||||
problems = allProblems,
|
||||
sessions = allSessions,
|
||||
attempts = allAttempts
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths = referencedImagePaths.filter { imagePath ->
|
||||
try {
|
||||
val imageFile = com.atridad.openclimb.utils.ImageUtils.getImageFile(context, imagePath)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}.toSet()
|
||||
|
||||
val validImagePaths =
|
||||
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
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = exportData,
|
||||
referencedImagePaths = validImagePaths
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun importDataFromZip(file: File) {
|
||||
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)
|
||||
|
||||
|
||||
// Validate JSON content
|
||||
if (importResult.jsonContent.isBlank()) {
|
||||
throw Exception("Invalid ZIP file: no data.json found or empty content")
|
||||
}
|
||||
|
||||
|
||||
// Parse and validate the data structure
|
||||
val importData = try {
|
||||
json.decodeFromString<ClimbDataExport>(importResult.jsonContent)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Invalid data format: ${e.message}")
|
||||
}
|
||||
|
||||
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 ->
|
||||
try {
|
||||
@@ -194,13 +217,14 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import gym ${gym.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import problems with updated image paths
|
||||
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
val updatedProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
updatedProblems.forEach { problem ->
|
||||
try {
|
||||
problemDao.insertProblem(problem)
|
||||
@@ -208,7 +232,7 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import problem ${problem.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import sessions
|
||||
importData.sessions.forEach { session ->
|
||||
try {
|
||||
@@ -217,7 +241,7 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import session: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Import attempts last (depends on problems and sessions)
|
||||
importData.attempts.forEach { attempt ->
|
||||
try {
|
||||
@@ -226,61 +250,66 @@ class ClimbRepository(
|
||||
throw Exception("Failed to import attempt: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Import failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
sessions: List<ClimbSession>,
|
||||
attempts: List<Attempt>
|
||||
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")
|
||||
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")
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
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) {
|
||||
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
|
||||
@@ -288,15 +317,14 @@ class ClimbRepository(
|
||||
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
|
||||
@@ -314,10 +342,10 @@ class ClimbRepository(
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class ClimbDataExport(
|
||||
val exportedAt: String,
|
||||
val version: String = "1.0",
|
||||
val gyms: List<Gym>,
|
||||
val problems: List<Problem>,
|
||||
val sessions: List<ClimbSession>,
|
||||
val attempts: List<Attempt>
|
||||
)
|
||||
val exportedAt: String,
|
||||
val version: String = "1.0",
|
||||
val gyms: List<Gym>,
|
||||
val problems: List<Problem>,
|
||||
val sessions: List<ClimbSession>,
|
||||
val attempts: List<Attempt>
|
||||
)
|
||||
|
||||
@@ -4,36 +4,33 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class BottomNavigationItem(
|
||||
val screen: Screen,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
)
|
||||
data class BottomNavigationItem(val screen: Screen, val icon: ImageVector, val label: String)
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Sessions,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
label = "Sessions"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Problems,
|
||||
icon = Icons.Default.Star,
|
||||
label = "Problems"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Analytics,
|
||||
icon = Icons.Default.Info,
|
||||
label = "Analytics"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Gyms,
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = "Gyms"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Settings,
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
)
|
||||
val bottomNavigationItems =
|
||||
listOf(
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Sessions,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
label = "Sessions"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Problems,
|
||||
icon = Icons.Default.Star,
|
||||
label = "Problems"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Analytics,
|
||||
icon = Icons.Default.Info,
|
||||
label = "Analytics"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Gyms,
|
||||
icon = Icons.Default.LocationOn,
|
||||
label = "Gyms"
|
||||
),
|
||||
BottomNavigationItem(
|
||||
screen = Screen.Settings,
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.atridad.openclimb.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -13,6 +11,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -25,260 +24,348 @@ import com.atridad.openclimb.navigation.Screen
|
||||
import com.atridad.openclimb.navigation.bottomNavigationItems
|
||||
import com.atridad.openclimb.ui.components.NotificationPermissionDialog
|
||||
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.ClimbViewModelFactory
|
||||
import com.atridad.openclimb.utils.AppShortcutManager
|
||||
import com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OpenClimbApp() {
|
||||
fun OpenClimbApp(
|
||||
shortcutAction: String? = null,
|
||||
lastUsedGymId: String? = null,
|
||||
onShortcutActionProcessed: () -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
var lastUsedGym by remember { mutableStateOf<com.atridad.openclimb.data.model.Gym?>(null) }
|
||||
|
||||
val database = remember { OpenClimbDatabase.getDatabase(context) }
|
||||
val repository = remember { ClimbRepository(database, context) }
|
||||
val viewModel: ClimbViewModel = viewModel(
|
||||
factory = ClimbViewModelFactory(repository)
|
||||
)
|
||||
|
||||
val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository))
|
||||
|
||||
// Notification permission state
|
||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
// Permission launcher
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
// Handle permission result
|
||||
if (isGranted) {
|
||||
// Permission granted, continue
|
||||
} else {
|
||||
// Permission denied, show dialog again later
|
||||
showNotificationPermissionDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check notification permission on first launch
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
if (!isGranted) {
|
||||
showNotificationPermissionDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasCheckedNotificationPermission) {
|
||||
hasCheckedNotificationPermission = true
|
||||
|
||||
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
|
||||
) {
|
||||
showNotificationPermissionDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure session tracking service is running when app resumes
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.ensureSessionTrackingServiceRunning(context)
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
|
||||
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
// Update last used gym when gyms change
|
||||
LaunchedEffect(gyms) {
|
||||
if (gyms.isNotEmpty() && lastUsedGym == null) {
|
||||
lastUsedGym = viewModel.getLastUsedGym()
|
||||
}
|
||||
}
|
||||
|
||||
// FAB configuration
|
||||
|
||||
LaunchedEffect(activeSession, gyms, lastUsedGym) {
|
||||
AppShortcutManager.updateShortcuts(
|
||||
context = context,
|
||||
hasActiveSession = activeSession != null,
|
||||
hasGyms = gyms.isNotEmpty(),
|
||||
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(shortcutAction) {
|
||||
when (shortcutAction) {
|
||||
AppShortcutManager.ACTION_START_SESSION -> {
|
||||
navController.navigate(Screen.Sessions) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
AppShortcutManager.ACTION_END_SESSION -> {
|
||||
navController.navigate(Screen.Sessions) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
activeSession?.let { session -> viewModel.endSession(context, session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process shortcut actions after data is loaded
|
||||
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
|
||||
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
|
||||
)
|
||||
|
||||
if (activeSession == null) {
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(
|
||||
context
|
||||
)
|
||||
) {
|
||||
android.util.Log.d("OpenClimbApp", "Showing notification permission dialog")
|
||||
showNotificationPermissionDialog = true
|
||||
} else {
|
||||
if (gyms.size == 1) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Starting session with single gym: ${gyms.first().name}"
|
||||
)
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
// Try to get the last used gym from the intent or fallback to state
|
||||
val targetGym =
|
||||
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
|
||||
?: lastUsedGym
|
||||
|
||||
if (targetGym != null) {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Starting session with target gym: ${targetGym.name}"
|
||||
)
|
||||
viewModel.startSession(context, targetGym.id)
|
||||
} else {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"No target gym found, navigating to selection"
|
||||
)
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d(
|
||||
"OpenClimbApp",
|
||||
"Active session already exists: ${activeSession?.id}"
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the shortcut action after processing to prevent repeated execution
|
||||
onShortcutActionProcessed()
|
||||
}
|
||||
}
|
||||
|
||||
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
OpenClimbBottomNavigation(navController = navController)
|
||||
},
|
||||
floatingActionButton = {
|
||||
fabConfig?.let { config ->
|
||||
FloatingActionButton(
|
||||
onClick = config.onClick,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = config.icon,
|
||||
contentDescription = config.contentDescription
|
||||
)
|
||||
bottomBar = { OpenClimbBottomNavigation(navController = navController) },
|
||||
floatingActionButton = {
|
||||
fabConfig?.let { config ->
|
||||
FloatingActionButton(
|
||||
onClick = config.onClick,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
imageVector = config.icon,
|
||||
contentDescription = config.contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Sessions,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
navController = navController,
|
||||
startDestination = Screen.Sessions,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
// Main screens
|
||||
composable<Screen.Sessions> {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
LaunchedEffect(gyms, activeSession) {
|
||||
fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.PlayArrow,
|
||||
contentDescription = "Start Session",
|
||||
onClick = {
|
||||
// Check notification permission before starting session
|
||||
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
|
||||
showNotificationPermissionDialog = true
|
||||
} else {
|
||||
if (gyms.size == 1) {
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
fabConfig =
|
||||
if (gyms.isNotEmpty() && activeSession == null) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.PlayArrow,
|
||||
contentDescription = "Start Session",
|
||||
onClick = {
|
||||
if (NotificationPermissionUtils
|
||||
.shouldRequestNotificationPermission() &&
|
||||
!NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(
|
||||
context
|
||||
)
|
||||
) {
|
||||
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(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
}
|
||||
viewModel = viewModel,
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.Problems> {
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
LaunchedEffect(gyms) {
|
||||
fabConfig = if (gyms.isNotEmpty()) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Problem",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditProblem())
|
||||
fabConfig =
|
||||
if (gyms.isNotEmpty()) {
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Problem",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditProblem())
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ProblemsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
viewModel = viewModel,
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.Analytics> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = null // No FAB for analytics
|
||||
}
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AnalyticsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.Gyms> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Gym",
|
||||
onClick = {
|
||||
navController.navigate(Screen.AddEditGym())
|
||||
}
|
||||
)
|
||||
fabConfig =
|
||||
FabConfig(
|
||||
icon = Icons.Default.Add,
|
||||
contentDescription = "Add Gym",
|
||||
onClick = { navController.navigate(Screen.AddEditGym()) }
|
||||
)
|
||||
}
|
||||
GymsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
}
|
||||
viewModel = viewModel,
|
||||
onNavigateToGymDetail = { gymId ->
|
||||
navController.navigate(Screen.GymDetail(gymId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.Settings> {
|
||||
LaunchedEffect(Unit) {
|
||||
fabConfig = null // No FAB for settings
|
||||
}
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Detail screens
|
||||
|
||||
composable<Screen.SessionDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.SessionDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
SessionDetailScreen(
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
sessionId = args.sessionId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.ProblemDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
ProblemDetailScreen(
|
||||
problemId = args.problemId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { problemId ->
|
||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||
}
|
||||
problemId = args.problemId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { problemId ->
|
||||
navController.navigate(Screen.AddEditProblem(problemId = problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.GymDetail> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.GymDetail>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
GymDetailScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { gymId ->
|
||||
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
||||
},
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
},
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { gymId ->
|
||||
navController.navigate(Screen.AddEditGym(gymId = gymId))
|
||||
},
|
||||
onNavigateToSessionDetail = { sessionId ->
|
||||
navController.navigate(Screen.SessionDetail(sessionId))
|
||||
},
|
||||
onNavigateToProblemDetail = { problemId ->
|
||||
navController.navigate(Screen.ProblemDetail(problemId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.AddEditGym> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditGym>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditGymScreen(
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.AddEditProblem> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditProblemScreen(
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
problemId = args.problemId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
composable<Screen.AddEditSession> { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Screen.AddEditSession>()
|
||||
LaunchedEffect(Unit) { fabConfig = null }
|
||||
AddEditSessionScreen(
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
sessionId = args.sessionId,
|
||||
gymId = args.gymId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Notification permission dialog
|
||||
if (showNotificationPermissionDialog) {
|
||||
NotificationPermissionDialog(
|
||||
onDismiss = { showNotificationPermissionDialog = false },
|
||||
onRequestPermission = {
|
||||
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
|
||||
}
|
||||
onDismiss = { showNotificationPermissionDialog = false },
|
||||
onRequestPermission = {
|
||||
permissionLauncher.launch(
|
||||
NotificationPermissionUtils.getNotificationPermissionString()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -288,44 +375,41 @@ fun OpenClimbApp() {
|
||||
fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
|
||||
NavigationBar {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
val isSelected = when (item.screen) {
|
||||
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
||||
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
||||
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
||||
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
||||
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
||||
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
||||
}
|
||||
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Clear the entire back stack and go to the selected tab's root screen
|
||||
popUpTo(0) {
|
||||
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
|
||||
val isSelected =
|
||||
when (item.screen) {
|
||||
is Screen.Sessions -> currentRoute?.contains("Session") == true
|
||||
is Screen.Problems -> currentRoute?.contains("Problem") == true
|
||||
is Screen.Gyms -> currentRoute?.contains("Gym") == true
|
||||
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
|
||||
is Screen.Settings -> currentRoute?.contains("Settings") == true
|
||||
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
|
||||
}
|
||||
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Clear the entire back stack and go to the selected tab's root screen
|
||||
popUpTo(0) { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FabConfig(
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -132,14 +132,12 @@ fun ProgressChartCard(
|
||||
progressData: List<ProgressDataPoint>,
|
||||
problems: List<com.atridad.openclimb.data.model.Problem>,
|
||||
) {
|
||||
// Find all grading systems that have been used
|
||||
val usedSystems = remember(problems) {
|
||||
problems.map { it.difficulty.system }.distinct().filter { system ->
|
||||
problems.any { it.difficulty.system == system }
|
||||
}
|
||||
// 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 {
|
||||
var selectedSystem by remember(usedSystems) {
|
||||
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -8,346 +8,429 @@ import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.service.SessionTrackingService
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
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.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class ClimbViewModel(
|
||||
private val repository: ClimbRepository
|
||||
) : ViewModel() {
|
||||
|
||||
class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
|
||||
|
||||
// UI State flows
|
||||
private val _uiState = MutableStateFlow(ClimbUiState())
|
||||
val uiState: StateFlow<ClimbUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Data flows
|
||||
val gyms = repository.getAllGyms().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val problems = repository.getAllProblems().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val sessions = repository.getAllSessions().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val activeSession = repository.getActiveSessionFlow().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
val attempts = repository.getAllAttempts().stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
|
||||
|
||||
// Data flows
|
||||
val gyms =
|
||||
repository
|
||||
.getAllGyms()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val problems =
|
||||
repository
|
||||
.getAllProblems()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val sessions =
|
||||
repository
|
||||
.getAllSessions()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val activeSession =
|
||||
repository
|
||||
.getActiveSessionFlow()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
val attempts =
|
||||
repository
|
||||
.getAllAttempts()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
// Gym operations
|
||||
fun addGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.insertGym(gym) }
|
||||
}
|
||||
|
||||
fun addGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.updateGym(gym) }
|
||||
}
|
||||
|
||||
fun updateGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun deleteGym(gym: Gym) {
|
||||
viewModelScope.launch { repository.deleteGym(gym) }
|
||||
}
|
||||
|
||||
fun deleteGym(gym: Gym, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteGym(gym)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGymById(id: String): Flow<Gym?> = flow {
|
||||
emit(repository.getGymById(id))
|
||||
}
|
||||
|
||||
|
||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.insertProblem(problem) }
|
||||
}
|
||||
|
||||
fun addProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertProblem(problem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.updateProblem(problem) }
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateProblem(problem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun deleteProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
// Delete associated images
|
||||
problem.imagePaths.forEach { imagePath ->
|
||||
ImageUtils.deleteImage(context, imagePath)
|
||||
}
|
||||
|
||||
problem.imagePaths.forEach { imagePath -> ImageUtils.deleteImage(context, imagePath) }
|
||||
|
||||
repository.deleteProblem(problem)
|
||||
|
||||
// Clean up any remaining orphaned images
|
||||
|
||||
cleanupOrphanedImages(context)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun cleanupOrphanedImages(context: Context) {
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||
}
|
||||
|
||||
fun getProblemById(id: String): Flow<Problem?> = flow {
|
||||
emit(repository.getProblemById(id))
|
||||
}
|
||||
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> =
|
||||
repository.getProblemsByGym(gymId)
|
||||
|
||||
|
||||
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
|
||||
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = repository.getProblemsByGym(gymId)
|
||||
|
||||
// Session operations
|
||||
fun addSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.insertSession(session) }
|
||||
}
|
||||
|
||||
fun addSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.updateSession(session) }
|
||||
}
|
||||
|
||||
fun updateSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun deleteSession(session: ClimbSession) {
|
||||
viewModelScope.launch { repository.deleteSession(session) }
|
||||
}
|
||||
|
||||
fun deleteSession(session: ClimbSession, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteSession(session)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getSessionById(id: String): Flow<ClimbSession?> = flow {
|
||||
emit(repository.getSessionById(id))
|
||||
}
|
||||
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
repository.getSessionsByGym(gymId)
|
||||
|
||||
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
repository.getSessionsByGym(gymId)
|
||||
|
||||
// Get last used gym for shortcut functionality
|
||||
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
|
||||
|
||||
// Active session management
|
||||
fun startSession(context: Context, gymId: String, notes: String? = null) {
|
||||
viewModelScope.launch {
|
||||
// Check notification permission first
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "Notification permission is required to track your climbing session. Please enable notifications in settings."
|
||||
)
|
||||
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
|
||||
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
val existingActive = repository.getActiveSession()
|
||||
if (existingActive != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "There's already an active session. Please end it first."
|
||||
android.util.Log.d(
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "Creating new session")
|
||||
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
|
||||
repository.insertSession(newSession)
|
||||
|
||||
|
||||
android.util.Log.d(
|
||||
"ClimbViewModel",
|
||||
"Starting tracking service for session: ${newSession.id}"
|
||||
)
|
||||
// Start the tracking service
|
||||
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
|
||||
context.startForegroundService(serviceIntent)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
message = "Session started successfully!"
|
||||
)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
||||
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun endSession(context: Context, sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
// Check notification permission first
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = "Notification permission is required to manage your climbing session. Please enable notifications in settings."
|
||||
)
|
||||
if (!com.atridad.openclimb.utils.NotificationPermissionUtils
|
||||
.isNotificationPermissionGranted(context)
|
||||
) {
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
error =
|
||||
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
val session = repository.getSessionById(sessionId)
|
||||
if (session != null && session.status == SessionStatus.ACTIVE) {
|
||||
val completedSession = with(ClimbSession) { session.complete() }
|
||||
repository.updateSession(completedSession)
|
||||
|
||||
// Stop the tracking service, passing the session id so service can finalize if needed
|
||||
|
||||
val serviceIntent = SessionTrackingService.createStopIntent(context, sessionId)
|
||||
context.startService(serviceIntent)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
message = "Session completed!"
|
||||
)
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
_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) {
|
||||
viewModelScope.launch {
|
||||
val activeSession = repository.getActiveSession()
|
||||
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
|
||||
// Check if service is running by trying to start it again
|
||||
// The service will handle duplicate starts gracefully
|
||||
val serviceIntent = SessionTrackingService.createStartIntent(context, activeSession.id)
|
||||
val serviceIntent =
|
||||
SessionTrackingService.createStartIntent(context, activeSession.id)
|
||||
context.startForegroundService(serviceIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Attempt operations
|
||||
fun addAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.insertAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun addAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun deleteAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.deleteAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun deleteAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun updateAttempt(attempt: Attempt) {
|
||||
viewModelScope.launch { repository.updateAttempt(attempt) }
|
||||
}
|
||||
|
||||
fun updateAttempt(attempt: Attempt, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsByProblem(problemId)
|
||||
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsBySession(sessionId)
|
||||
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
repository.getAttemptsByProblem(problemId)
|
||||
|
||||
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
repository.exportAllDataToZipUri(context, uri)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data with images exported successfully"
|
||||
)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "Data with images exported successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Export failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun importData(file: File) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
// Only support ZIP format for reliability
|
||||
|
||||
if (!file.name.lowercase().endsWith(".zip")) {
|
||||
throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
|
||||
throw Exception(
|
||||
"Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
repository.importDataFromZip(file)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
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) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Import failed: ${e.message}"
|
||||
)
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Import failed: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// UI state operations
|
||||
fun clearMessage() {
|
||||
_uiState.value = _uiState.value.copy(message = null)
|
||||
}
|
||||
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
|
||||
fun setError(message: String) {
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
|
||||
fun resetAllData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
|
||||
repository.resetAllData()
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "All data has been reset successfully"
|
||||
)
|
||||
|
||||
_uiState.value =
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
message = "All data has been reset successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "Reset failed: ${e.message}"
|
||||
)
|
||||
_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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
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) {
|
||||
SessionShareUtils.shareSessionCard(context, imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
data class ClimbUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
val isLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
110
app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
Normal file
110
app/src/main/java/com/atridad/openclimb/utils/ShortcutManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/drawable-night/ic_play_arrow_24.xml
Normal file
9
app/src/main/res/drawable-night/ic_play_arrow_24.xml
Normal 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>
|
||||
9
app/src/main/res/drawable-night/ic_stop_24.xml
Normal file
9
app/src/main/res/drawable-night/ic_stop_24.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal file
9
app/src/main/res/drawable/ic_play_arrow_24.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_stop_24.xml
Normal file
9
app/src/main/res/drawable/ic_stop_24.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/widget_background.xml
Normal file
15
app/src/main/res/drawable/widget_background.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/widget_stat_card_background.xml
Normal file
15
app/src/main/res/drawable/widget_stat_card_background.xml
Normal 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>
|
||||
19
app/src/main/res/drawable/widget_status_background.xml
Normal file
19
app/src/main/res/drawable/widget_status_background.xml
Normal 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>
|
||||
195
app/src/main/res/layout/widget_climb_stats.xml
Normal file
195
app/src/main/res/layout/widget_climb_stats.xml
Normal 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>
|
||||
@@ -2,5 +2,14 @@
|
||||
<resources>
|
||||
<!-- Splash background (dark) -->
|
||||
<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>
|
||||
|
||||
@@ -10,4 +10,14 @@
|
||||
|
||||
<!-- Splash background (light) -->
|
||||
<color name="splash_background">#FFFFFFFF</color>
|
||||
</resources>
|
||||
|
||||
<!-- Widget colors (light theme) -->
|
||||
<color name="widget_background">#FFFFFFFF</color>
|
||||
<color name="widget_surface">#FFF8F9FA</color>
|
||||
<color name="widget_outline">#FFE0E0E0</color>
|
||||
<color name="widget_primary">#FF1976D2</color>
|
||||
<color name="widget_secondary">#FF388E3C</color>
|
||||
<color name="widget_accent">#FFFF5722</color>
|
||||
<color name="widget_text_primary">#FF212121</color>
|
||||
<color name="widget_text_secondary">#FF757575</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
<resources>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
<!-- App Shortcuts -->
|
||||
<string name="shortcut_start_session_short">Start Session</string>
|
||||
<string name="shortcut_start_session_long">Start a new climbing session</string>
|
||||
<string name="shortcut_start_session_disabled">No gyms available to start session</string>
|
||||
|
||||
<string name="shortcut_end_session_short">End Session</string>
|
||||
<string name="shortcut_end_session_long">End current climbing session</string>
|
||||
<string name="shortcut_end_session_disabled">No active session to end</string>
|
||||
|
||||
<!-- Widget -->
|
||||
<string name="widget_description">View your climbing stats at a glance</string>
|
||||
</resources>
|
||||
|
||||
17
app/src/main/res/xml/widget_climb_stats_info.xml
Normal file
17
app/src/main/res/xml/widget_climb_stats_info.xml
Normal 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" />
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.12.1"
|
||||
agp = "8.12.2"
|
||||
kotlin = "2.2.10"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
|
||||
Reference in New Issue
Block a user