Compare commits

..

19 Commits
0.3.3 ... 1.1.2

Author SHA1 Message Date
cf2adeef7a 1.1.2 - More fixes for notification reliability 2025-08-22 23:22:23 -06:00
a7481135b4 1.1.1 - More fixes for notification reliabilityyyyy 2025-08-22 21:00:08 -06:00
748a23e1c0 1.1.1 - More fixes for notification reliability 2025-08-22 20:59:36 -06:00
f078cfc6e1 1.1.0 - Export/Import overhaul 2025-08-22 20:39:19 -06:00
8bb1f422c1 1.0.1 - Notification reliability update... again 2025-08-22 19:13:40 -06:00
327dfba425 1.0.1 - Notification reliability update 2025-08-22 19:11:21 -06:00
96759e402d 1.0.0 - 1.0 baybeeeee 2025-08-22 16:19:25 -06:00
ed76fb2fb2 Remove outdated comment 2025-08-22 10:56:43 -06:00
870278f240 0.5.0 - Optimizations and better session management 2025-08-19 00:35:04 -06:00
4eef77bd3b 0.4.5 2025-08-18 09:53:40 -06:00
2d957db948 0.4.5 2025-08-18 09:52:53 -06:00
22bed6a961 Merge pull request 'Updated to support API version 36 properly' (#2) from dependencies into main
Reviewed-on: atridad/OpenClimb#2
2025-08-18 15:49:36 +00:00
b443c18a19 Updated to support API version 36 properly 2025-08-18 00:46:28 -06:00
89f1e350b3 0.4.4 - Cleaned up range views 2025-08-17 01:29:15 -06:00
0f976f685f 0.4.3 - Removing Average Grade... its not useful 2025-08-17 01:13:10 -06:00
c07186a7df 0.4.2 - Fixed issue with photo upload streams 2025-08-17 00:57:48 -06:00
15a5e217a5 0.4.1 - Small fix for share image 2025-08-16 20:20:30 -06:00
b86ab591fe Readme changes w obtainium 2025-08-16 19:06:39 -06:00
70c85d159e 0.4.0 - Bug fixes and improvements 2025-08-16 19:04:11 -06:00
34 changed files with 2593 additions and 1667 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Gradle files # Gradle files
.gradle/ .gradle/
build/ build/
release/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties

View File

@@ -817,6 +817,19 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2424" /> <option name="screenY" value="2424" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />

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-08-23T05:20:48.817593Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=18171FDF6007NW" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

3
.idea/misc.xml generated
View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" /> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="temurin-21" project-jdk-type="JavaSDK" />
</project> </project>

View File

@@ -7,7 +7,7 @@ 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 Released page
2. Use <a href="">Obtainium</a> 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>
## Requirements ## Requirements

View File

@@ -8,14 +8,14 @@ plugins {
android { android {
namespace = "com.atridad.openclimb" namespace = "com.atridad.openclimb"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 33
targetSdk = 35 targetSdk = 36
versionCode = 6 versionCode = 17
versionName = "0.3.3" versionName = "1.1.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -30,12 +30,19 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "17"
} }
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
@@ -74,13 +81,16 @@ dependencies {
// Image Loading // Image Loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Charts - Placeholder for future implementation
// Charts will be implemented with a stable library in future versions
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)

View File

@@ -27,7 +27,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.OpenClimb"> android:theme="@style/Theme.OpenClimb.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -51,7 +51,8 @@
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" />

View File

@@ -13,6 +13,7 @@ import com.atridad.openclimb.ui.theme.OpenClimbTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.Theme_OpenClimb)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
OpenClimbTheme { OpenClimbTheme {

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

@@ -68,4 +68,41 @@ data class DifficultyGrade(
val system: DifficultySystem, val system: DifficultySystem,
val grade: String, val grade: String,
val numericValue: Int val numericValue: Int
) ) {
/**
* Compare this grade with another grade of the same system
* Returns negative if this grade is easier, positive if harder, 0 if equal
*/
fun compareTo(other: DifficultyGrade): Int {
if (system != other.system) return 0
return when (system) {
DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade)
DifficultySystem.FONT -> compareFontGrades(grade, other.grade)
DifficultySystem.YDS -> compareYDSGrades(grade, other.grade)
DifficultySystem.CUSTOM -> grade.compareTo(other.grade)
}
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
return grade1.compareTo(grade2)
}
}

View File

@@ -64,181 +64,148 @@ class ClimbRepository(
// JSON Export // ZIP Export with images - Single format for reliability
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 {
try {
// Collect all data with proper error handling
val allGyms = gymDao.getAllGyms().first() val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first() val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first() val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first() val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(), exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms, gyms = allGyms,
problems = allProblems, problems = allProblems,
sessions = allSessions, sessions = allSessions,
attempts = allAttempts attempts = allAttempts
) )
// Collect all referenced image paths // Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths = referencedImagePaths.filter { imagePath ->
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( return ZipExportImportUtils.createExportZip(
context = context, context = context,
exportData = exportData, exportData = exportData,
referencedImagePaths = referencedImagePaths, referencedImagePaths = validImagePaths,
directory = directory 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()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = ClimbDataExport( val exportData = ClimbDataExport(
exportedAt = LocalDateTime.now().toString(), exportedAt = LocalDateTime.now().toString(),
gyms = gyms, version = "1.0",
problems = problems, gyms = allGyms,
sessions = sessions, problems = allProblems,
attempts = attempts sessions = allSessions,
attempts = allAttempts
) )
// Collect all image paths // Collect all referenced image paths and validate they exist
val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() 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()
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = exportData, exportData = exportData,
referencedImagePaths = referencedImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
} }
suspend fun importDataFromZip(file: File) { suspend fun importDataFromZip(file: File) {
try { try {
val importResult = ZipExportImportUtils.extractImportZip(context, file) // Validate the ZIP file
val importData = json.decodeFromString<ClimbDataExport>(importResult.jsonContent) if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Update problem image paths with the new imported paths // 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}")
}
// 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 {
gymDao.insertGym(gym)
} catch (e: Exception) {
throw Exception("Failed to import gym ${gym.name}: ${e.message}")
}
}
// Import problems with updated image paths
val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( val updatedProblems = ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
// Import gyms
importData.gyms.forEach { gym ->
try {
gymDao.insertGym(gym)
} catch (e: Exception) {
// If insertion fails update instead
gymDao.updateGym(gym)
}
}
// Import problems with updated image paths
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,21 +214,100 @@ 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}")
} }
} }
} }
@@ -269,6 +315,7 @@ class ClimbRepository(
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class ClimbDataExport( data class ClimbDataExport(
val exportedAt: String, val exportedAt: String,
val version: String = "1.0",
val gyms: List<Gym>, val gyms: List<Gym>,
val problems: List<Problem>, val problems: List<Problem>,
val sessions: List<ClimbSession>, val sessions: List<ClimbSession>,

View File

@@ -16,13 +16,16 @@ 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 lateinit var repository: ClimbRepository private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object { companion object {
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
@@ -51,6 +54,7 @@ class SessionTrackingService : Service() {
val database = OpenClimbDatabase.getDatabase(this) val database = OpenClimbDatabase.getDatabase(this)
repository = ClimbRepository(database, this) repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel() createNotificationChannel()
} }
@@ -81,50 +85,126 @@ class SessionTrackingService : Service() {
} }
} }
} }
return START_STICKY
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) { private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
try {
createAndShowNotification(sessionId)
} catch (e: Exception) {
e.printStackTrace()
}
notificationJob = serviceScope.launch { notificationJob = serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) { while (isActive) {
delay(5000L)
updateNotification(sessionId) updateNotification(sessionId)
delay(1000) }
} catch (e: Exception) {
e.printStackTrace()
}
}
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.openclimb.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
e.printStackTrace()
} }
} }
} }
private fun stopSessionTracking() { private fun stopSessionTracking() {
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
} catch (e: Exception) {
false
}
}
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 {
val start = LocalDateTime.parse(startTime) val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now() val now = LocalDateTime.now()
val minutes = ChronoUnit.MINUTES.between(start, now) val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = minutes / 60 val hours = totalSeconds / 3600
val remainingMinutes = minutes % 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${remainingMinutes}m" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
remainingMinutes > 0 -> "${remainingMinutes}m" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "< 1m" else -> "${totalSeconds}s"
} }
} catch (_: Exception) { } catch (_: Exception) {
"Active" "Active"
@@ -136,7 +216,10 @@ class SessionTrackingService : Service() {
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts") .setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent()) .setContentIntent(createOpenAppIntent())
.addAction( .addAction(
R.drawable.ic_mountains, R.drawable.ic_mountains,
@@ -151,15 +234,19 @@ class SessionTrackingService : Service() {
.build() .build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} catch (_: Exception) {
// Handle errors gracefully notificationManager.notify(NOTIFICATION_ID, notification)
stopSessionTracking()
} catch (e: Exception) {
e.printStackTrace()
throw e
} }
} }
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
@@ -183,19 +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
enableLights(false)
enableVibration(false)
setSound(null, null)
} }
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationJob?.cancel() notificationJob?.cancel()
monitoringJob?.cancel()
serviceScope.cancel() serviceScope.cancel()
} }
} }

View File

@@ -1,8 +1,13 @@
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.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -18,9 +23,12 @@ import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.navigation.Screen 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.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.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -34,6 +42,40 @@ fun OpenClimbApp() {
factory = ClimbViewModelFactory(repository) 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
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
}
}
}
// Ensure session tracking service is running when app resumes
LaunchedEffect(Unit) {
viewModel.ensureSessionTrackingServiceRunning(context)
}
// FAB configuration // FAB configuration
var fabConfig by remember { mutableStateOf<FabConfig?>(null) } var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
@@ -67,15 +109,21 @@ fun OpenClimbApp() {
LaunchedEffect(gyms, activeSession) { LaunchedEffect(gyms, activeSession) {
fabConfig = if (gyms.isNotEmpty() && activeSession == null) { fabConfig = if (gyms.isNotEmpty() && activeSession == null) {
FabConfig( FabConfig(
icon = Icons.Default.Add, icon = Icons.Default.PlayArrow,
contentDescription = "Start Session", contentDescription = "Start Session",
onClick = { onClick = {
// Check notification permission before starting session
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)) {
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) { if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
}
) )
} else { } else {
null null
@@ -152,8 +200,8 @@ fun OpenClimbApp() {
sessionId = args.sessionId, sessionId = args.sessionId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { sessionId -> onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.AddEditSession(sessionId = sessionId)) navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -180,6 +228,12 @@ fun OpenClimbApp() {
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId -> onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId)) navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
} }
) )
} }
@@ -208,6 +262,7 @@ fun OpenClimbApp() {
composable<Screen.AddEditSession> { backStackEntry -> composable<Screen.AddEditSession> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.AddEditSession>() val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen( AddEditSessionScreen(
sessionId = args.sessionId, sessionId = args.sessionId,
gymId = args.gymId, gymId = args.gymId,
@@ -216,6 +271,16 @@ fun OpenClimbApp() {
) )
} }
} }
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(NotificationPermissionUtils.getNotificationPermissionString())
}
)
}
} }
} }

View File

@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -13,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.data.model.ClimbSession import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.ui.theme.CustomIcons
import kotlinx.coroutines.delay
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@@ -24,6 +25,16 @@ fun ActiveSessionBanner(
onEndSession: () -> Unit onEndSession: () -> Unit
) { ) {
if (activeSession != null) { if (activeSession != null) {
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(1000) // Update every second
currentTime = LocalDateTime.now()
}
}
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -67,7 +78,7 @@ fun ActiveSessionBanner(
) )
activeSession.startTime?.let { startTime -> activeSession.startTime?.let { startTime ->
val duration = calculateDuration(startTime) val duration = calculateDuration(startTime, currentTime)
Text( Text(
text = duration, text = duration,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -84,7 +95,7 @@ fun ActiveSessionBanner(
) )
) { ) {
Icon( Icon(
Icons.Default.Close, imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session" contentDescription = "End session"
) )
} }
@@ -93,18 +104,18 @@ fun ActiveSessionBanner(
} }
} }
private fun calculateDuration(startTimeString: String): String { private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String {
return try { return try {
val startTime = LocalDateTime.parse(startTimeString) val startTime = LocalDateTime.parse(startTimeString)
val now = LocalDateTime.now() val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime)
val minutes = ChronoUnit.MINUTES.between(startTime, now) val hours = totalSeconds / 3600
val hours = minutes / 60 val minutes = (totalSeconds % 3600) / 60
val remainingMinutes = minutes % 60 val seconds = totalSeconds % 60
when { when {
hours > 0 -> "${hours}h ${remainingMinutes}m" hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
remainingMinutes > 0 -> "${remainingMinutes}m" minutes > 0 -> "${minutes}m ${seconds}s"
else -> "< 1m" else -> "${totalSeconds}s"
} }
} catch (_: Exception) { } catch (_: Exception) {
"Active" "Active"

View File

@@ -0,0 +1,89 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun NotificationPermissionDialog(
onDismiss: () -> Unit,
onRequestPermission: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = "Notifications",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.headlineSmall,
fontWeight = MaterialTheme.typography.headlineSmall.fontWeight,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "OpenClimb needs notification permission to show your active climbing session. This helps you track your progress and ensures the session doesn't get interrupted.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Not Now")
}
Button(
onClick = {
onRequestPermission()
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Text("Enable")
}
}
}
}
}
}

View File

@@ -5,11 +5,9 @@ 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
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,21 +16,13 @@ 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.window.Dialog 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
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.time.LocalDateTime import java.time.LocalDateTime
// Data class for attempt input
data class AttemptInput(
val problemId: String,
val result: AttemptResult,
val highestHold: String = "",
val notes: String = ""
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddEditGymScreen( fun AddEditGymScreen(
@@ -91,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)
} }
@@ -278,7 +268,6 @@ fun AddEditProblemScreen(
notes = p.notes ?: "" notes = p.notes ?: ""
isActive = p.isActive isActive = p.isActive
imagePaths = p.imagePaths imagePaths = p.imagePaths
// Set the selected gym for the existing problem
selectedGym = gyms.find { it.id == p.gymId } selectedGym = gyms.find { it.id == p.gymId }
} }
} }
@@ -360,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)
} }
@@ -700,7 +689,7 @@ fun AddEditSessionScreen(
) { ) {
val isEditing = sessionId != null val isEditing = sessionId != null
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val problems by viewModel.problems.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 } }) }
@@ -708,10 +697,6 @@ fun AddEditSessionScreen(
var duration by remember { mutableStateOf("") } var duration by remember { mutableStateOf("") }
var sessionNotes by remember { mutableStateOf("") } var sessionNotes by remember { mutableStateOf("") }
// Attempt tracking state
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
var showAddAttemptDialog by remember { mutableStateOf(false) }
// Load existing session data for editing // Load existing session data for editing
LaunchedEffect(sessionId) { LaunchedEffect(sessionId) {
if (sessionId != null) { if (sessionId != null) {
@@ -744,26 +729,14 @@ fun AddEditSessionScreen(
TextButton( TextButton(
onClick = { onClick = {
selectedGym?.let { gym -> selectedGym?.let { gym ->
if (isEditing) {
val session = ClimbSession.create( val session = ClimbSession.create(
gymId = gym.id, gymId = gym.id,
notes = sessionNotes.ifBlank { null } notes = sessionNotes.ifBlank { null }
) )
viewModel.updateSession(session.copy(id = sessionId!!))
if (isEditing) {
viewModel.updateSession(session.copy(id = sessionId))
} else { } else {
viewModel.addSession(session) viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null })
attempts.forEach { attemptInput ->
val attempt = Attempt.create(
sessionId = session.id,
problemId = attemptInput.problemId,
result = attemptInput.result,
highestHold = attemptInput.highestHold.ifBlank { null },
notes = attemptInput.notes.ifBlank { null }
)
viewModel.addAttempt(attempt)
}
} }
onNavigateBack() onNavigateBack()
} }
@@ -774,15 +747,6 @@ fun AddEditSessionScreen(
} }
} }
) )
},
floatingActionButton = {
if (selectedGym != null) {
FloatingActionButton(
onClick = { showAddAttemptDialog = true }
) {
Icon(Icons.Default.Add, contentDescription = "Add Attempt")
}
}
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumn(
@@ -878,285 +842,9 @@ fun AddEditSessionScreen(
} }
} }
} }
// Attempts Section
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Attempts (${attempts.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
} }
if (attempts.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No attempts recorded yet. Add an attempt to track your progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Attempts List
items(attempts.size) { index ->
val attempt = attempts[index]
val problem = problems.find { it.id == attempt.problemId }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = problem?.name ?: "Unknown Problem",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
problem?.difficulty?.let { difficulty ->
Text(
text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}",
style = MaterialTheme.typography.bodyMedium,
color = when (attempt.result) {
AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
if (attempt.highestHold.isNotBlank()) {
Text(
text = "Highest hold: ${attempt.highestHold}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (attempt.notes.isNotBlank()) {
Text(
text = attempt.notes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = {
attempts = attempts.toMutableList().apply { removeAt(index) }
}
) {
Icon(Icons.Default.Delete, contentDescription = "Remove attempt")
}
}
}
}
}
}
}
if (showAddAttemptDialog && selectedGym != null) {
AddAttemptDialog(
problems = problems.filter { it.gymId == selectedGym!!.id && it.isActive },
onDismiss = { showAddAttemptDialog = false },
onAddAttempt = { attemptInput ->
attempts = attempts + attemptInput
showAddAttemptDialog = false
}
)
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddAttemptDialog(
problems: List<Problem>,
onDismiss: () -> Unit,
onAddAttempt: (AttemptInput) -> Unit
) {
var selectedProblem by remember { mutableStateOf<Problem?>(null) }
var selectedResult by remember { mutableStateOf(AttemptResult.FALL) }
var highestHold by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Add Attempt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
// Problem Selection
Text(
text = "Problem",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
if (problems.isEmpty()) {
Text(
text = "No active problems in this gym. Add some problems first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
LazyColumn(
modifier = Modifier.height(120.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(problems) { problem ->
Card(
onClick = { selectedProblem = problem },
colors = CardDefaults.cardColors(
containerColor = if (selectedProblem?.id == problem.id)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Text(
text = problem.name ?: "Unnamed Problem",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Result Selection
Text(
text = "Result",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Column(modifier = Modifier.selectableGroup()) {
AttemptResult.entries.forEach { result ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedResult == result,
onClick = { selectedResult = result },
role = Role.RadioButton
)
) {
RadioButton(
selected = selectedResult == result,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = result.name.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Highest Hold
OutlinedTextField(
value = highestHold,
onValueChange = { highestHold = it },
label = { Text("Highest Hold (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., 'jugs near the top', 'crux move'") }
)
// Notes
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (Optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") }
)
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
Button(
onClick = {
selectedProblem?.let { problem ->
onAddAttempt(
AttemptInput(
problemId = problem.id,
result = selectedResult,
highestHold = highestHold,
notes = notes
)
)
}
},
enabled = selectedProblem != null,
modifier = Modifier.weight(1f)
) {
Text("Add Attempt")
}
}
}
}
}
}

View File

@@ -93,41 +93,6 @@ fun AnalyticsScreen(
val recentSessions = sessions.take(5) val recentSessions = sessions.take(5)
RecentActivityCard(recentSessions = recentSessions.size) RecentActivityCard(recentSessions = recentSessions.size)
} }
item {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Progress Charts",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Detailed charts and analytics coming soon!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "📊",
style = MaterialTheme.typography.displaySmall
)
}
}
}
} }
} }

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

@@ -0,0 +1,25 @@
package com.atridad.openclimb.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object CustomIcons {
fun Stop(color: Color = Color.Black): ImageVector = ImageVector.Builder(
name = "Stop",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).path(
fill = SolidColor(color)
) {
moveTo(6f, 6f)
horizontalLineTo(18f)
verticalLineTo(18f)
horizontalLineTo(6f)
close()
}.build()
}

View File

@@ -147,6 +147,14 @@ class ClimbViewModel(
// 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
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."
)
return@launch
}
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -170,6 +178,14 @@ class ClimbViewModel(
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.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) val session = repository.getSessionById(sessionId)
if (session != null && session.status == SessionStatus.ACTIVE) { if (session != null && session.status == SessionStatus.ACTIVE) {
val completedSession = with(ClimbSession) { session.complete() } val completedSession = with(ClimbSession) { session.complete() }
@@ -186,6 +202,21 @@ class ClimbViewModel(
} }
} }
/**
* 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)
context.startForegroundService(serviceIntent)
}
}
}
// Attempt operations // Attempt operations
fun addAttempt(attempt: Attempt) { fun addAttempt(attempt: Attempt) {
viewModelScope.launch { viewModelScope.launch {
@@ -193,29 +224,25 @@ class ClimbViewModel(
} }
} }
fun deleteAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.deleteAttempt(attempt)
}
}
fun updateAttempt(attempt: Attempt) {
viewModelScope.launch {
repository.updateAttempt(attempt)
}
}
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 {
@@ -240,13 +267,13 @@ 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 // Only support ZIP format for reliability
if (file.name.lowercase().endsWith(".zip")) { if (!file.name.lowercase().endsWith(".zip")) {
repository.importDataFromZip(file) throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.")
} else {
repository.importDataFromJson(file)
} }
repository.importDataFromZip(file)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data imported successfully from ${file.name}" message = "Data imported successfully from ${file.name}"
@@ -273,6 +300,26 @@ class ClimbViewModel(
_uiState.value = _uiState.value.copy(error = message) _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"
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Reset failed: ${e.message}"
)
}
}
}
// Share operations // Share operations
suspend fun generateSessionShareCard( suspend fun generateSessionShareCard(
context: Context, context: Context,

View File

@@ -35,11 +35,13 @@ object ImageUtils {
*/ */
fun saveImageFromUri(context: Context, imageUri: Uri): String? { fun saveImageFromUri(context: Context, imageUri: Uri): String? {
return try { return try {
val inputStream = context.contentResolver.openInputStream(imageUri) // Decode bitmap from a fresh stream to avoid mark/reset dependency
inputStream?.use { input -> val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
// Decode and compress the image BitmapFactory.decodeStream(input)
val originalBitmap = BitmapFactory.decodeStream(input) } ?: return null
val compressedBitmap = compressImage(originalBitmap)
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
// Generate unique filename // Generate unique filename
val filename = "${UUID.randomUUID()}.jpg" val filename = "${UUID.randomUUID()}.jpg"
@@ -52,17 +54,73 @@ object ImageUtils {
// Clean up bitmaps // Clean up bitmaps
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle() compressedBitmap.recycle()
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null
} }
} }
/**
* Corrects image orientation based on EXIF data
*/
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = android.media.ExifInterface(input)
val orientation = exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
}
if (matrix.isIdentity) {
bitmap
} else {
android.graphics.Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
}
} ?: bitmap
} catch (e: Exception) {
e.printStackTrace()
bitmap
}
}
/** /**
* Compresses and resizes an image bitmap * Compresses and resizes an image bitmap
*/ */

View File

@@ -0,0 +1,33 @@
package com.atridad.openclimb.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
object NotificationPermissionUtils {
/**
* Check if notification permission is granted
*/
fun isNotificationPermissionGranted(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/**
* Check if notification permission should be requested
*/
fun shouldRequestNotificationPermission(): Boolean {
return true
}
/**
* Get the notification permission string
*/
fun getNotificationPermissionString(): String {
return Manifest.permission.POST_NOTIFICATIONS
}
}

View File

@@ -24,7 +24,8 @@ object SessionShareUtils {
val uniqueProblemsCompleted: Int, val uniqueProblemsCompleted: Int,
val averageGrade: String?, val averageGrade: String?,
val sessionDuration: String, val sessionDuration: String,
val topResult: AttemptResult? val topResult: AttemptResult?,
val topGrade: String?
) )
fun calculateSessionStats( fun calculateSessionStats(
@@ -42,15 +43,34 @@ object SessionShareUtils {
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems } val attemptedProblems = problems.filter { it.id in uniqueProblems }
val averageGrade = if (attemptedProblems.isNotEmpty()) {
// This is a simplified average - in reality you'd need proper grade conversion // Calculate separate averages for different climbing types and difficulty systems
val gradeValues = attemptedProblems.mapNotNull { problem -> val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display
val averageGrade = when {
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope)
val topGrade = when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder
topRope != null -> topRope
else -> null
} }
if (gradeValues.isNotEmpty()) {
"V${gradeValues.average().roundToInt()}"
} else null
} else null
val duration = if (session.duration != null) "${session.duration}m" else "Unknown" val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult = attempts.maxByOrNull { val topResult = attempts.maxByOrNull {
@@ -70,10 +90,81 @@ object SessionShareUtils {
uniqueProblemsCompleted = uniqueCompletedProblems.size, uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade, averageGrade = averageGrade,
sessionDuration = duration, sessionDuration = duration,
topResult = topResult topResult = topResult,
topGrade = topGrade
) )
} }
/**
* Calculate average grade for a specific set of problems, respecting their difficulty systems
*/
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): 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
}
fun generateShareCard( fun generateShareCard(
context: Context, context: Context,
session: ClimbSession, session: ClimbSession,
@@ -81,8 +172,8 @@ object SessionShareUtils {
stats: SessionStats stats: SessionStats
): File? { ): File? {
return try { return try {
val width = 1080 val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1350 val height = 1656
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -136,7 +227,7 @@ object SessionShareUtils {
} }
// Draw main card background // Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 100f) val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content // Draw content
@@ -157,31 +248,48 @@ object SessionShareUtils {
// Stats grid // Stats grid
val statsStartY = yPosition val statsStartY = yPosition
val columnWidth = width / 2f val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f
// Left column stats // Left column stats
var leftY = statsStartY var leftY = statsStartY
drawStatItem(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
leftY += 140f leftY += 120f
drawStatItem(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint) drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth)
// Right column stats // Right column stats
var rightY = statsStartY var rightY = statsStartY
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint) drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 140f rightY += 120f
stats.averageGrade?.let { grade -> var rightYAfter = rightY
drawStatItem(canvas, width - columnWidth / 2f, rightY, "Avg Grade", grade, statLabelPaint, statValuePaint) stats.topGrade?.let { grade ->
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth)
rightYAfter += 120f
}
// Grade range(s)
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER })
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth)
} else if (boulderRange != null || ropeRange != null) {
// Single centered item
val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f)
} }
// Success rate arc // Success rate arc
if (stats.totalAttempts > 0) { val successRate = if (stats.totalAttempts > 0) {
val successRate = (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100 (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f
drawSuccessRateArc(canvas, width / 2f, height - 280f, successRate, statLabelPaint, statValuePaint) } else 0f
} drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint)
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint = Paint().apply {
@@ -229,6 +337,41 @@ object SessionShareUtils {
canvas.drawText(label, x, y + 50f, labelPaint) canvas.drawText(label, x, y + 50f, labelPaint)
} }
/**
* Draws a stat item while fitting the value text to a max width by reducing text size if needed.
*/
private fun drawStatItemFitting(
canvas: Canvas,
x: Float,
y: Float,
label: String,
value: String,
labelPaint: Paint,
valuePaint: Paint,
maxTextWidth: Float
) {
val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize
var textWidth = tempPaint.measureText(value)
while (textWidth > maxTextWidth && textSize > 36f) {
textSize -= 2f
tempPaint.textSize = textSize
textWidth = tempPaint.measureText(value)
}
canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint)
}
/**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/
private fun gradeRangeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
val grades = problems.map { it.difficulty }
val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}"
}
private fun drawSuccessRateArc( private fun drawSuccessRateArc(
canvas: Canvas, canvas: Canvas,
centerX: Float, centerX: Float,
@@ -237,12 +380,12 @@ object SessionShareUtils {
labelPaint: Paint, labelPaint: Paint,
valuePaint: Paint valuePaint: Paint
) { ) {
val radius = 80f val radius = 70f
val strokeWidth = 16f val strokeWidth = 14f
// Background arc // Background arc
val bgPaint = Paint().apply { val bgPaint = Paint().apply {
color = "#40FFFFFF".toColorInt() color = "#30FFFFFF".toColorInt()
style = Paint.Style.STROKE style = Paint.Style.STROKE
this.strokeWidth = strokeWidth this.strokeWidth = strokeWidth
isAntiAlias = true isAntiAlias = true
@@ -269,8 +412,11 @@ object SessionShareUtils {
// Draw percentage text // Draw percentage text
val percentText = "${successRate.roundToInt()}%" val percentText = "${successRate.roundToInt()}%"
canvas.drawText(percentText, centerX, centerY + 10f, valuePaint) canvas.drawText(percentText, centerX, centerY + 8f, valuePaint)
canvas.drawText("Success Rate", centerX, centerY + 60f, labelPaint)
// 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 {
@@ -307,4 +453,48 @@ object SessionShareUtils {
e.printStackTrace() e.printStackTrace()
} }
} }
/**
* Returns the highest grade string among the given problems, respecting their difficulty system.
*/
private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade
}
/**
* Produces a comparable numeric rank for grades across supported systems.
*/
private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) {
DifficultySystem.V_SCALE -> {
if (grade == "VB") 0.0 else grade.removePrefix("V").toDoubleOrNull() ?: -1.0
}
DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
}
DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d
val s = grade.lowercase()
if (!s.startsWith("5.")) return -1.0
val tail = s.removePrefix("5.")
val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight = when (letterPart) {
'a' -> 0.0
'b' -> 0.1
'c' -> 0.2
'd' -> 0.3
else -> 0.0
}
base + letterWeight
}
DifficultySystem.CUSTOM -> {
grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() ?: -1.0
}
}
}
} }

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,9 +39,20 @@ 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")
try {
ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> ZipOutputStream(FileOutputStream(zipFile)).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { prettyPrint = true } val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -48,11 +60,12 @@ object ZipExportImportUtils {
zipOut.write(jsonString.toByteArray()) zipOut.write(jsonString.toByteArray())
zipOut.closeEntry() zipOut.closeEntry()
// Add images // Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath -> referencedImagePaths.forEach { imagePath ->
try { try {
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists()) { if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry) zipOut.putNextEntry(imageEntry)
@@ -60,15 +73,33 @@ object ZipExportImportUtils {
imageInput.copyTo(zipOut) imageInput.copyTo(zipOut)
} }
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath")
} }
} catch (e: Exception) { } catch (e: Exception) {
// Log error but continue with other images android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
e.printStackTrace()
}
} }
} }
// 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 return zipFile
} catch (e: Exception) {
// Clean up failed export
if (zipFile.exists()) {
zipFile.delete()
}
throw IOException("Failed to create export ZIP: ${e.message}")
}
} }
/** /**
@@ -84,10 +115,21 @@ object ZipExportImportUtils {
exportData: com.atridad.openclimb.data.repository.ClimbDataExport, exportData: com.atridad.openclimb.data.repository.ClimbDataExport,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) { ) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream ->
ZipOutputStream(outputStream).use { zipOut -> ZipOutputStream(outputStream).use { zipOut ->
// Add metadata file first
val metadata = createMetadata(exportData, referencedImagePaths)
val metadataEntry = ZipEntry(METADATA_FILENAME)
zipOut.putNextEntry(metadataEntry)
zipOut.write(metadata.toByteArray())
zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { prettyPrint = true } val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -95,11 +137,12 @@ object ZipExportImportUtils {
zipOut.write(jsonString.toByteArray()) zipOut.write(jsonString.toByteArray())
zipOut.closeEntry() zipOut.closeEntry()
// Add images // Add images with validation
var successfulImages = 0
referencedImagePaths.forEach { imagePath -> referencedImagePaths.forEach { imagePath ->
try { try {
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
if (imageFile.exists()) { if (imageFile.exists() && imageFile.length() > 0) {
val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}")
zipOut.putNextEntry(imageEntry) zipOut.putNextEntry(imageEntry)
@@ -107,14 +150,38 @@ object ZipExportImportUtils {
imageInput.copyTo(zipOut) imageInput.copyTo(zipOut)
} }
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++
} }
} catch (e: Exception) { } catch (e: Exception) {
// Log error but continue with other images android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}")
e.printStackTrace()
} }
} }
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,22 +200,34 @@ 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>()
try {
ZipInputStream(FileInputStream(zipFile)).use { zipIn -> ZipInputStream(FileInputStream(zipFile)).use { zipIn ->
var entry = zipIn.nextEntry var entry = zipIn.nextEntry
while (entry != null) { while (entry != null) {
when { when {
entry.name == METADATA_FILENAME -> {
// Read metadata for validation
metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}")
}
entry.name == DATA_JSON_FILENAME -> { entry.name == DATA_JSON_FILENAME -> {
// Read JSON data // Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
} }
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file // Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
try {
// Create temporary file to hold the extracted image // Create temporary file to hold the extracted image
val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir) val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir)
@@ -156,14 +235,30 @@ object ZipExportImportUtils {
zipIn.copyTo(output) zipIn.copyTo(output)
} }
// Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) {
// Import the image to permanent storage // Import the image to permanent storage
val newPath = ImageUtils.importImageFile(context, tempFile) val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) { if (newPath != null) {
importedImagePaths[originalFilename] = newPath 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 // Clean up temp file
tempFile.delete() 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}")
} }
} }
@@ -172,11 +267,22 @@ object ZipExportImportUtils {
} }
} }
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")
} }
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) return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}")
}
} }
/** /**

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash background (dark) -->
<color name="splash_background">#FF121212</color>
</resources>

View File

@@ -7,4 +7,7 @@
<color name="teal_700">#FF018786</color> <color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Splash background (light) -->
<color name="splash_background">#FFFFFFFF</color>
</resources> </resources>

View File

@@ -1,3 +1,4 @@
<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>
</resources> </resources>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.OpenClimb" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.OpenClimb.Splash" parent="Theme.OpenClimb">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
<item name="android:windowSplashScreenAnimationDuration">200</item>
</style>
</resources> </resources>

View File

@@ -1,10 +1,14 @@
[versions] [versions]
agp = "8.9.1" agp = "8.12.1"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.15.0" coreKtx = "1.15.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"
androidxTestExt = "1.2.0"
androidxTestRunner = "1.6.0"
androidxTestRules = "1.6.0"
lifecycleRuntimeKtx = "2.9.2" lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.09.00" composeBom = "2024.09.00"
@@ -21,6 +25,10 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref =
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
@@ -48,6 +56,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Coroutines # Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
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" }

View File

@@ -1,6 +1,6 @@
#Fri Aug 15 11:23:25 MDT 2025 #Fri Aug 15 11:23:25 MDT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists