Compare commits

...

12 Commits

43 changed files with 5486 additions and 3388 deletions

View File

@@ -5,7 +5,7 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems,
## Versions ## Versions
- Android:1.4.2 - Android:1.4.2
- iOS: 1.0.0 - iOS: 1.0.1
## Download ## Download
@@ -16,7 +16,7 @@ For Android do one of the following:
For iOS: For iOS:
**Stay tuned for an upcoming Testflight or App Store release!** Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
## Requirements ## Requirements

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 27
versionName = "1.4.2" versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -55,6 +55,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database // Room Database
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
@@ -92,4 +93,3 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
} }

View File

@@ -11,8 +11,7 @@ interface ProblemDao {
@Query("SELECT * FROM problems ORDER BY updatedAt DESC") @Query("SELECT * FROM problems ORDER BY updatedAt DESC")
fun getAllProblems(): Flow<List<Problem>> fun getAllProblems(): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE id = :id") @Query("SELECT * FROM problems WHERE id = :id") suspend fun getProblemById(id: String): Problem?
suspend fun getProblemById(id: String): Problem?
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
fun getProblemsByGym(gymId: String): Flow<List<Problem>> fun getProblemsByGym(gymId: String): Flow<List<Problem>>
@@ -20,7 +19,9 @@ interface ProblemDao {
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>> fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC") @Query(
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC"
)
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>> fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
@@ -29,20 +30,16 @@ interface ProblemDao {
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>> fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblem(problem: Problem)
suspend fun insertProblem(problem: Problem)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProblems(problems: List<Problem>) suspend fun insertProblems(problems: List<Problem>)
@Update @Update suspend fun updateProblem(problem: Problem)
suspend fun updateProblem(problem: Problem)
@Delete @Delete suspend fun deleteProblem(problem: Problem)
suspend fun deleteProblem(problem: Problem)
@Query("DELETE FROM problems WHERE id = :id") @Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String)
suspend fun deleteProblemById(id: String)
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId") @Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
suspend fun getProblemsCountByGym(gymId: String): Int suspend fun getProblemsCountByGym(gymId: String): Int
@@ -50,19 +47,18 @@ interface ProblemDao {
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1") @Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
suspend fun getActiveProblemsCount(): Int suspend fun getActiveProblemsCount(): Int
@Query(""" @Query(
"""
SELECT * FROM problems SELECT * FROM problems
WHERE (name LIKE '%' || :searchQuery || '%' WHERE (name LIKE '%' || :searchQuery || '%'
OR description LIKE '%' || :searchQuery || '%' OR description LIKE '%' || :searchQuery || '%'
OR location LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%')
OR setter LIKE '%' || :searchQuery || '%')
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") @Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems") @Query("DELETE FROM problems") suspend fun deleteAllProblems()
suspend fun deleteAllProblems()
} }

View File

@@ -0,0 +1,226 @@
package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable
/** Root structure for OpenClimb backup data */
@Serializable
data class ClimbDataBackup(
val exportedAt: String,
val version: String = "2.0",
val formatVersion: String = "2.0",
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>
)
/** Platform-neutral gym representation for backup/restore */
@Serializable
data class BackupGym(
val id: String,
val name: String,
val location: String? = null,
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
) {
companion object {
/** Create BackupGym from native Android Gym model */
fun fromGym(gym: Gym): BackupGym {
return BackupGym(
id = gym.id,
name = gym.name,
location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades,
notes = gym.notes,
createdAt = gym.createdAt,
updatedAt = gym.updatedAt
)
}
}
/** Convert to native Android Gym model */
fun toGym(): Gym {
return Gym(
id = id,
name = name,
location = location,
supportedClimbTypes = supportedClimbTypes,
difficultySystems = difficultySystems,
customDifficultyGrades = customDifficultyGrades,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
}
/** Platform-neutral problem representation for backup/restore */
@Serializable
data class BackupProblem(
val id: String,
val gymId: String,
val name: String? = null,
val description: String? = null,
val climbType: ClimbType,
val difficulty: DifficultyGrade,
val tags: List<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String>? = null,
val isActive: Boolean = true,
val dateSet: String? = null, // ISO 8601 format
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
) {
companion object {
/** Create BackupProblem from native Android Problem model */
fun fromProblem(problem: Problem): BackupProblem {
return BackupProblem(
id = problem.id,
gymId = problem.gymId,
name = problem.name,
description = problem.description,
climbType = problem.climbType,
difficulty = problem.difficulty,
tags = problem.tags,
location = problem.location,
imagePaths = problem.imagePaths.ifEmpty { null },
isActive = problem.isActive,
dateSet = problem.dateSet,
notes = problem.notes,
createdAt = problem.createdAt,
updatedAt = problem.updatedAt
)
}
}
/** Convert to native Android Problem model */
fun toProblem(): Problem {
return Problem(
id = id,
gymId = gymId,
name = name,
description = description,
climbType = climbType,
difficulty = difficulty,
tags = tags,
location = location,
imagePaths = imagePaths ?: emptyList(),
isActive = isActive,
dateSet = dateSet,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
/** Create a copy with updated image paths for import processing */
fun withUpdatedImagePaths(newImagePaths: List<String>): BackupProblem {
return copy(imagePaths = newImagePaths.ifEmpty { null })
}
}
/** Platform-neutral climb session representation for backup/restore */
@Serializable
data class BackupClimbSession(
val id: String,
val gymId: String,
val date: String, // ISO 8601 format
val startTime: String? = null, // ISO 8601 format
val endTime: String? = null, // ISO 8601 format
val duration: Long? = null, // Duration in seconds
val status: SessionStatus,
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
) {
companion object {
/** Create BackupClimbSession from native Android ClimbSession model */
fun fromClimbSession(session: ClimbSession): BackupClimbSession {
return BackupClimbSession(
id = session.id,
gymId = session.gymId,
date = session.date,
startTime = session.startTime,
endTime = session.endTime,
duration = session.duration,
status = session.status,
notes = session.notes,
createdAt = session.createdAt,
updatedAt = session.updatedAt
)
}
}
/** Convert to native Android ClimbSession model */
fun toClimbSession(): ClimbSession {
return ClimbSession(
id = id,
gymId = gymId,
date = date,
startTime = startTime,
endTime = endTime,
duration = duration,
status = status,
notes = notes,
createdAt = createdAt,
updatedAt = updatedAt
)
}
}
/** Platform-neutral attempt representation for backup/restore */
@Serializable
data class BackupAttempt(
val id: String,
val sessionId: String,
val problemId: String,
val result: AttemptResult,
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null, // Duration in seconds
val restTime: Long? = null, // Rest time in seconds
val timestamp: String, // ISO 8601 format
val createdAt: String // ISO 8601 format
) {
companion object {
/** Create BackupAttempt from native Android Attempt model */
fun fromAttempt(attempt: Attempt): BackupAttempt {
return BackupAttempt(
id = attempt.id,
sessionId = attempt.sessionId,
problemId = attempt.problemId,
result = attempt.result,
highestHold = attempt.highestHold,
notes = attempt.notes,
duration = attempt.duration,
restTime = attempt.restTime,
timestamp = attempt.timestamp,
createdAt = attempt.createdAt
)
}
}
/** Convert to native Android Attempt model */
fun toAttempt(): Attempt {
return Attempt(
id = id,
sessionId = sessionId,
problemId = problemId,
result = result,
highestHold = highestHold,
notes = notes,
duration = duration,
restTime = restTime,
timestamp = timestamp,
createdAt = createdAt
)
}
}

View File

@@ -4,31 +4,29 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.serialization.Serializable
@Entity( @Entity(
tableName = "problems", tableName = "problems",
foreignKeys = [ foreignKeys =
[
ForeignKey( ForeignKey(
entity = Gym::class, entity = Gym::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["gymId"], childColumns = ["gymId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )],
],
indices = [Index(value = ["gymId"])] indices = [Index(value = ["gymId"])]
) )
@Serializable @Serializable
data class Problem( data class Problem(
@PrimaryKey @PrimaryKey val id: String,
val id: String,
val gymId: String, val gymId: String,
val name: String? = null, val name: String? = null,
val description: String? = null, val description: String? = null,
val climbType: ClimbType, val climbType: ClimbType,
val difficulty: DifficultyGrade, val difficulty: DifficultyGrade,
val setter: String? = null,
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),
val location: String? = null, val location: String? = null,
val imagePaths: List<String> = emptyList(), val imagePaths: List<String> = emptyList(),
@@ -45,7 +43,6 @@ data class Problem(
description: String? = null, description: String? = null,
climbType: ClimbType, climbType: ClimbType,
difficulty: DifficultyGrade, difficulty: DifficultyGrade,
setter: String? = null,
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
location: String? = null, location: String? = null,
imagePaths: List<String> = emptyList(), imagePaths: List<String> = emptyList(),
@@ -60,7 +57,6 @@ data class Problem(
description = description, description = description,
climbType = climbType, climbType = climbType,
difficulty = difficulty, difficulty = difficulty,
setter = setter,
tags = tags, tags = tags,
location = location, location = location,
imagePaths = imagePaths, imagePaths = imagePaths,

View File

@@ -2,6 +2,11 @@ package com.atridad.openclimb.data.repository
import android.content.Context import android.content.Context
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ZipExportImportUtils import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File import java.io.File
@@ -27,7 +32,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym)
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
// Problem operations // Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems() fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
@@ -36,7 +40,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
// Session operations // Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions() fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
@@ -67,69 +70,9 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
// ZIP Export with images - Single format for reliability
suspend fun exportAllDataToZip(directory: File? = null): File {
try {
// Collect all data with proper error handling
val allGyms = gymDao.getAllGyms().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(
exportedAt = LocalDateTime.now().toString(),
version = "1.0",
gyms = allGyms,
problems = allProblems,
sessions = allSessions,
attempts = allAttempts
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
// Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) {
android.util.Log.w(
"ClimbRepository",
"Some referenced images are missing: $missingImages"
)
}
return ZipExportImportUtils.createExportZip(
context = context,
exportData = exportData,
referencedImagePaths = validImagePaths,
directory = directory
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try { try {
// Collect all data with proper error handling // Collect all data
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()
@@ -138,14 +81,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
// Validate data integrity before export // Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val exportData = // Create backup data using platform-neutral format
ClimbDataExport( val backupData =
ClimbDataBackup(
exportedAt = LocalDateTime.now().toString(), exportedAt = LocalDateTime.now().toString(),
version = "1.0", version = "2.0",
gyms = allGyms, formatVersion = "2.0",
problems = allProblems, gyms = allGyms.map { BackupGym.fromGym(it) },
sessions = allSessions, problems = allProblems.map { BackupProblem.fromProblem(it) },
attempts = allAttempts sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
) )
// Collect all referenced image paths and validate they exist // Collect all referenced image paths and validate they exist
@@ -160,7 +105,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
imagePath imagePath
) )
imageFile.exists() && imageFile.length() > 0 imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} }
@@ -169,7 +114,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
ZipExportImportUtils.createExportZipToUri( ZipExportImportUtils.createExportZipToUri(
context = context, context = context,
uri = uri, uri = uri,
exportData = exportData, exportData = backupData,
referencedImagePaths = validImagePaths referencedImagePaths = validImagePaths
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -195,7 +140,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
// Parse and validate the data structure // Parse and validate the data structure
val importData = val importData =
try { try {
json.decodeFromString<ClimbDataExport>(importResult.jsonContent) json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}") throw Exception("Invalid data format: ${e.message}")
} }
@@ -210,44 +155,47 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
gymDao.deleteAllGyms() gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) // Import gyms first (problems depend on gyms)
importData.gyms.forEach { gym -> importData.gyms.forEach { backupGym ->
try { try {
gymDao.insertGym(gym) gymDao.insertGym(backupGym.toGym())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to import gym ${gym.name}: ${e.message}") throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
} }
} }
// Import problems with updated image paths // Import problems with updated image paths
val updatedProblems = val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths( ZipExportImportUtils.updateProblemImagePaths(
importData.problems, importData.problems,
importResult.importedImagePaths importResult.importedImagePaths
) )
updatedProblems.forEach { problem -> // Import problems (depends on gyms)
updatedBackupProblems.forEach { backupProblem ->
try { try {
problemDao.insertProblem(problem) problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to import problem ${problem.name}: ${e.message}") throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}"
)
} }
} }
// Import sessions // Import sessions
importData.sessions.forEach { session -> importData.sessions.forEach { backupSession ->
try { try {
sessionDao.insertSession(session) sessionDao.insertSession(backupSession.toClimbSession())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to import session: ${e.message}") throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
} }
} }
// Import attempts last (depends on problems and sessions) // Import attempts last (depends on problems and sessions)
importData.attempts.forEach { attempt -> importData.attempts.forEach { backupAttempt ->
try { try {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(backupAttempt.toAttempt())
} catch (e: Exception) { } catch (e: Exception) {
throw Exception("Failed to import attempt: ${e.message}") throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -291,7 +239,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
private fun validateImportData(importData: ClimbDataExport) { private fun validateImportData(importData: ClimbDataBackup) {
if (importData.gyms.isEmpty()) { if (importData.gyms.isEmpty()) {
throw Exception("Import data is invalid: no gyms found") throw Exception("Import data is invalid: no gyms found")
} }
@@ -339,13 +287,3 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
} }
} }
@kotlinx.serialization.Serializable
data class ClimbDataExport(
val exportedAt: String,
val version: String = "1.0",
val gyms: List<Gym>,
val problems: List<Problem>,
val sessions: List<ClimbSession>,
val attempts: List<Attempt>
)

View File

@@ -0,0 +1,208 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Data point for the bar chart */
data class BarChartDataPoint(val label: String, val value: Int, val gradeNumeric: Int)
/** Configuration for bar chart styling */
data class BarChartStyle(
val barColor: Color,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/** Custom Bar Chart for displaying grade distribution */
@Composable
fun BarChart(
data: List<BarChartDataPoint>,
modifier: Modifier = Modifier,
style: BarChartStyle =
BarChartStyle(
barColor = MaterialTheme.colorScheme.primary,
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Sort data by grade numeric value for proper ordering
val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value for scaling
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions
val barCount = sortedData.size
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
maxValue = maxValue,
textMeasurer = textMeasurer,
textColor = style.textColor
)
}
// Draw bars and labels
sortedData.forEachIndexed { index, dataPoint ->
val barHeight =
if (maxValue > 0) {
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
} else 0f
val barX =
padding +
barSpacing +
index * (barWidth + barSpacing / (barCount - 1).coerceAtLeast(1))
val barY = padding + chartHeight - barHeight
// Draw bar
drawRect(
color = style.barColor,
topLeft = Offset(barX, barY),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
)
// Draw value on top of bar (if there's space)
if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough
val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar
} else {
barY - 4.dp.toPx() // Above bar
}
val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar
} else {
style.textColor // Regular color above bar
}
drawText(
textMeasurer = textMeasurer,
text = valueText,
style = textStyle.copy(color = textColor),
topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY)
)
}
// Draw grade label below bar
val gradeText = dataPoint.label
val labelTextStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val labelTextSize = textMeasurer.measure(gradeText, labelTextStyle)
drawText(
textMeasurer = textMeasurer,
text = gradeText,
style = labelTextStyle,
topLeft =
Offset(
barX + barWidth / 2f - labelTextSize.size.width / 2f,
padding + chartHeight + 8.dp.toPx()
)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
maxValue: Int,
textMeasurer: TextMeasurer,
textColor: Color
) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis)
val gridLines =
when {
maxValue <= 5 -> (0..maxValue).toList()
maxValue <= 10 -> (0..maxValue step 2).toList()
maxValue <= 20 -> (0..maxValue step 5).toList()
else -> {
val step = (maxValue / 5).coerceAtLeast(1)
(0..maxValue step step).toList()
}
}
gridLines.forEach { value ->
val y = padding + chartHeight - (value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw Y-axis label
if (value >= 0) {
val text = value.toString()
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft =
Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}

View File

@@ -12,24 +12,20 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.components.ImagePicker
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import kotlinx.coroutines.flow.first
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.coroutines.flow.first
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddEditGymScreen( fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack: () -> Unit) {
gymId: String?,
viewModel: ClimbViewModel,
onNavigateBack: () -> Unit
) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") } var location by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") }
@@ -39,17 +35,19 @@ fun AddEditGymScreen(
val isEditing = gymId != null val isEditing = gymId != null
// Calculate available difficulty systems based on selected climb types // Calculate available difficulty systems based on selected climb types
val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) { val availableDifficultySystems =
if (selectedClimbTypes.isEmpty()) {
emptyList() emptyList()
} else { } else {
selectedClimbTypes.flatMap { climbType -> selectedClimbTypes
DifficultySystem.getSystemsForClimbType(climbType) .flatMap { climbType -> DifficultySystem.getSystemsForClimbType(climbType) }
}.distinct() .distinct()
} }
// Reset selected difficulty systems when available systems change // Reset selected difficulty systems when available systems change
LaunchedEffect(availableDifficultySystems) { LaunchedEffect(availableDifficultySystems) {
selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet() selectedDifficultySystems =
selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet()
} }
// Load existing gym data for editing // Load existing gym data for editing
@@ -72,13 +70,23 @@ fun AddEditGymScreen(
title = { Text(if (isEditing) "Edit Gym" else "Add Gym") }, title = { Text(if (isEditing) "Edit Gym" else "Add Gym") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
TextButton( TextButton(
onClick = { onClick = {
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!!))
@@ -87,19 +95,17 @@ fun AddEditGymScreen(
} }
onNavigateBack() onNavigateBack()
}, },
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty() enabled =
) { name.isNotBlank() &&
Text("Save") selectedClimbTypes.isNotEmpty() &&
} selectedDifficultySystems.isNotEmpty()
) { Text("Save") }
} }
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Name field // Name field
@@ -121,12 +127,8 @@ fun AddEditGymScreen(
) )
// Climb Types // Climb Types
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Supported Climb Types", text = "Supported Climb Types",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -138,15 +140,20 @@ fun AddEditGymScreen(
ClimbType.entries.forEach { climbType -> ClimbType.entries.forEach { climbType ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.selectable( .selectable(
selected = climbType in selectedClimbTypes, selected = climbType in selectedClimbTypes,
onClick = { onClick = {
selectedClimbTypes = if (climbType in selectedClimbTypes) { selectedClimbTypes =
selectedClimbTypes - climbType if (climbType in
selectedClimbTypes
) {
selectedClimbTypes -
climbType
} else { } else {
selectedClimbTypes + climbType selectedClimbTypes +
climbType
} }
}, },
role = Role.Checkbox role = Role.Checkbox
@@ -164,12 +171,8 @@ fun AddEditGymScreen(
} }
// Difficulty Systems // Difficulty Systems
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Difficulty Systems", text = "Difficulty Systems",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -180,7 +183,8 @@ fun AddEditGymScreen(
if (selectedClimbTypes.isEmpty()) { if (selectedClimbTypes.isEmpty()) {
Text( Text(
text = "Select climb types first to see available difficulty systems", text =
"Select climb types first to see available difficulty systems",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
@@ -189,15 +193,22 @@ fun AddEditGymScreen(
availableDifficultySystems.forEach { system -> availableDifficultySystems.forEach { system ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.selectable( .selectable(
selected = system in selectedDifficultySystems, selected =
system in
selectedDifficultySystems,
onClick = { onClick = {
selectedDifficultySystems = if (system in selectedDifficultySystems) { selectedDifficultySystems =
selectedDifficultySystems - system if (system in
selectedDifficultySystems
) {
selectedDifficultySystems -
system
} else { } else {
selectedDifficultySystems + system selectedDifficultySystems +
system
} }
}, },
role = Role.Checkbox role = Role.Checkbox
@@ -239,13 +250,15 @@ fun AddEditProblemScreen(
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
// Problem form state // Problem 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 } })
}
var problemName by remember { mutableStateOf("") } var problemName by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") } var description by remember { mutableStateOf("") }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember { mutableStateOf(DifficultySystem.V_SCALE) } var selectedDifficultySystem by remember { mutableStateOf(DifficultySystem.V_SCALE) }
var difficultyGrade by remember { mutableStateOf("") } var difficultyGrade by remember { mutableStateOf("") }
var setter by remember { mutableStateOf("") }
var location by remember { mutableStateOf("") } var location by remember { mutableStateOf("") }
var tags by remember { mutableStateOf("") } var tags by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") }
@@ -262,7 +275,7 @@ fun AddEditProblemScreen(
selectedClimbType = p.climbType selectedClimbType = p.climbType
selectedDifficultySystem = p.difficulty.system selectedDifficultySystem = p.difficulty.system
difficultyGrade = p.difficulty.grade difficultyGrade = p.difficulty.grade
setter = p.setter ?: ""
location = p.location ?: "" location = p.location ?: ""
tags = p.tags.joinToString(", ") tags = p.tags.joinToString(", ")
notes = p.notes ?: "" notes = p.notes ?: ""
@@ -280,7 +293,8 @@ fun AddEditProblemScreen(
} }
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList()
val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> val availableDifficultySystems =
DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
selectedGym?.difficultySystems?.contains(system) != false selectedGym?.difficultySystems?.contains(system) != false
} }
@@ -296,10 +310,12 @@ fun AddEditProblemScreen(
when { when {
// If current system is not compatible, select the first available one // If current system is not compatible, select the first available one
selectedDifficultySystem !in availableDifficultySystems -> { selectedDifficultySystem !in availableDifficultySystems -> {
selectedDifficultySystem = availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM selectedDifficultySystem =
availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
} }
// If there's only one available system and nothing is selected, auto-select it // If there's only one available system and nothing is selected, auto-select it
availableDifficultySystems.size == 1 && selectedDifficultySystem != availableDifficultySystems.first() -> { availableDifficultySystems.size == 1 &&
selectedDifficultySystem != availableDifficultySystems.first() -> {
selectedDifficultySystem = availableDifficultySystems.first() selectedDifficultySystem = availableDifficultySystems.first()
} }
} }
@@ -319,37 +335,60 @@ fun AddEditProblemScreen(
title = { Text(if (isEditing) "Edit Problem" else "Add Problem") }, title = { Text(if (isEditing) "Edit Problem" else "Add Problem") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
TextButton( TextButton(
onClick = { onClick = {
selectedGym?.let { gym -> selectedGym?.let { gym ->
val difficulty = DifficultyGrade( val difficulty =
DifficultyGrade(
system = selectedDifficultySystem, system = selectedDifficultySystem,
grade = difficultyGrade, grade = difficultyGrade,
numericValue = when (selectedDifficultySystem) { numericValue =
DifficultySystem.V_SCALE -> difficultyGrade.removePrefix("V").toIntOrNull() ?: 0 when (selectedDifficultySystem
else -> difficultyGrade.hashCode() % 100 // Simple mapping for other systems ) {
DifficultySystem.V_SCALE ->
difficultyGrade
.removePrefix(
"V"
)
.toIntOrNull()
?: 0
else ->
difficultyGrade
.hashCode() %
100 // Simple mapping for other systems
} }
) )
val problem = Problem.create( val problem =
Problem.create(
gymId = gym.id, gymId = gym.id,
name = problemName.ifBlank { null }, name = problemName.ifBlank { null },
description = description.ifBlank { null }, description =
description.ifBlank { null },
climbType = selectedClimbType, climbType = selectedClimbType,
difficulty = difficulty, difficulty = difficulty,
setter = setter.ifBlank { null }, tags =
tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, tags.split(",")
.map { it.trim() }
.filter {
it.isNotBlank()
},
location = location.ifBlank { null }, location = location.ifBlank { null },
imagePaths = imagePaths, imagePaths = imagePaths,
notes = notes.ifBlank { null } notes = notes.ifBlank { null }
) )
if (isEditing) { if (isEditing) {
viewModel.updateProblem(problem.copy(id = problemId!!)) viewModel.updateProblem(
problem.copy(id = problemId!!)
)
} else { } else {
viewModel.addProblem(problem) viewModel.addProblem(problem)
} }
@@ -357,28 +396,19 @@ fun AddEditProblemScreen(
} }
}, },
enabled = selectedGym != null && difficultyGrade.isNotBlank() enabled = selectedGym != null && difficultyGrade.isNotBlank()
) { ) { Text("Save") }
Text("Save")
}
} }
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Gym Selection // Gym Selection
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Select Gym", text = "Select Gym",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -394,9 +424,7 @@ fun AddEditProblemScreen(
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} else { } else {
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(gyms) { gym -> items(gyms) { gym ->
FilterChip( FilterChip(
onClick = { selectedGym = gym }, onClick = { selectedGym = gym },
@@ -412,12 +440,8 @@ fun AddEditProblemScreen(
// Basic Problem Info // Basic Problem Info
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Problem Details", text = "Problem Details",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -448,14 +472,6 @@ fun AddEditProblemScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = setter,
onValueChange = { setter = it },
label = { Text("Route Setter (Optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
@@ -473,12 +489,8 @@ fun AddEditProblemScreen(
// Climb Type // Climb Type
if (selectedGym != null) { if (selectedGym != null) {
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Climb Type", text = "Climb Type",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -487,9 +499,7 @@ fun AddEditProblemScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
availableClimbTypes.forEach { climbType -> availableClimbTypes.forEach { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
@@ -506,12 +516,8 @@ fun AddEditProblemScreen(
// Difficulty // Difficulty
if (selectedGym != null) { if (selectedGym != null) {
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Difficulty", text = "Difficulty",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -526,9 +532,7 @@ fun AddEditProblemScreen(
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(availableDifficultySystems) { system -> items(availableDifficultySystems) { system ->
FilterChip( FilterChip(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
@@ -545,16 +549,22 @@ fun AddEditProblemScreen(
value = difficultyGrade, value = difficultyGrade,
onValueChange = { newValue -> onValueChange = { newValue ->
// Only allow integers for custom scales // Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }) { if (newValue.isEmpty() || newValue.all { it.isDigit() }
) {
difficultyGrade = newValue difficultyGrade = newValue
} }
}, },
label = { Text("Grade *") }, label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") }, placeholder = {
supportingText = { Text("Custom grades must be whole numbers") }, Text("Enter numeric grade (e.g. 5, 10, 15)")
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) },
supportingText = {
Text("Custom grades must be whole numbers")
},
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Number)
) )
} else { } else {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -570,10 +580,21 @@ fun AddEditProblemScreen(
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Grade *") }, label = { Text("Grade *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = {
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), ExposedDropdownMenuDefaults.TrailingIcon(
modifier = Modifier expanded = expanded
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true) )
},
colors =
ExposedDropdownMenuDefaults
.outlinedTextFieldColors(),
modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
.PrimaryNotEditable,
enabled = true
)
.fillMaxWidth() .fillMaxWidth()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
@@ -599,12 +620,8 @@ fun AddEditProblemScreen(
// Images Section // Images Section
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Photos", text = "Photos",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -623,12 +640,8 @@ fun AddEditProblemScreen(
} }
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Additional Info", text = "Additional Info",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -661,18 +674,15 @@ fun AddEditProblemScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.selectable( .selectable(
selected = isActive, selected = isActive,
onClick = { isActive = !isActive }, onClick = { isActive = !isActive },
role = Role.Checkbox role = Role.Checkbox
) )
) { ) {
Checkbox( Checkbox(checked = isActive, onCheckedChange = null)
checked = isActive,
onCheckedChange = null
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Problem is currently active", text = "Problem is currently active",
@@ -699,7 +709,9 @@ fun AddEditSessionScreen(
val context = LocalContext.current 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 } })
}
var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) } var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) }
var duration by remember { mutableStateOf("") } var duration by remember { mutableStateOf("") }
var sessionNotes by remember { mutableStateOf("") } var sessionNotes by remember { mutableStateOf("") }
@@ -729,7 +741,10 @@ fun AddEditSessionScreen(
title = { Text(if (isEditing) "Edit Session" else "Add Session") }, title = { Text(if (isEditing) "Edit Session" else "Add Session") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
@@ -737,40 +752,41 @@ fun AddEditSessionScreen(
onClick = { onClick = {
selectedGym?.let { gym -> selectedGym?.let { gym ->
if (isEditing) { 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!!)
) )
viewModel.updateSession(session.copy(id = sessionId!!))
} else { } else {
viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null }) viewModel.startSession(
context,
gym.id,
sessionNotes.ifBlank { null }
)
} }
onNavigateBack() onNavigateBack()
} }
}, },
enabled = selectedGym != null enabled = selectedGym != null
) { ) { Text("Save") }
Text("Save")
}
} }
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Gym Selection // Gym Selection
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Select Gym", text = "Select Gym",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -786,9 +802,7 @@ fun AddEditSessionScreen(
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} else { } else {
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(gyms) { gym -> items(gyms) { gym ->
FilterChip( FilterChip(
onClick = { selectedGym = gym }, onClick = { selectedGym = gym },
@@ -804,12 +818,8 @@ fun AddEditSessionScreen(
// Session Details // Session Details
item { item {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Session Details", text = "Session Details",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -834,7 +844,8 @@ fun AddEditSessionScreen(
label = { Text("Duration (minutes)") }, label = { Text("Duration (minutes)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Number)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -852,6 +863,3 @@ fun AddEditSessionScreen(
} }
} }
} }

View File

@@ -10,25 +10,24 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.LineChart import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable @Composable
fun AnalyticsScreen( fun AnalyticsScreen(viewModel: ClimbViewModel) {
viewModel: ClimbViewModel
) {
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(16.dp),
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {
@@ -61,18 +60,17 @@ fun AnalyticsScreen(
) )
} }
// Progress Chart // Grade Distribution Chart
item { item {
val progressData = calculateProgressOverTime(sessions, problems, attempts) val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
ProgressChartCard(progressData = progressData, problems = problems) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
} }
// Favorite Gym // Favorite Gym
item { item {
val favoriteGym = sessions val favoriteGym =
.groupBy { it.gymId } sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
.maxByOrNull { it.value.size } (gymId, sessions) ->
?.let { (gymId, sessions) ->
gyms.find { it.id == gymId }?.name to sessions.size gyms.find { it.id == gymId }?.name to sessions.size
} }
@@ -91,20 +89,9 @@ fun AnalyticsScreen(
} }
@Composable @Composable
fun OverallStatsCard( fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) {
totalSessions: Int, Card(modifier = Modifier.fillMaxWidth()) {
totalProblems: Int, Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
totalAttempts: Int,
totalGyms: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Overall Stats", text = "Overall Stats",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -128,39 +115,66 @@ fun OverallStatsCard(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProgressChartCard( fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionDataPoint>) {
progressData: List<ProgressDataPoint>, // Find all grading systems that have been used in the data
problems: List<com.atridad.openclimb.data.model.Problem>, val usedSystems =
) { remember(gradeDistributionData) {
// Find all grading systems that have been used in the progress data gradeDistributionData.map { it.difficultySystem }.distinct()
val usedSystems = remember(progressData) {
progressData.map { it.difficultySystem }.distinct()
} }
var selectedSystem by remember(usedSystems) { var selectedSystem by
remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
} }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showAllTime by remember { mutableStateOf(true) }
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) { Text(
Column( text = "Grade Distribution",
modifier = Modifier style = MaterialTheme.typography.titleMedium,
.fillMaxWidth() fontWeight = FontWeight.Bold
.padding(16.dp) )
) {
Spacer(modifier = Modifier.height(12.dp))
// Toggles section
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( // Time period toggle
text = "Progress Over Time", Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
style = MaterialTheme.typography.titleMedium, // All Time button
fontWeight = FontWeight.Bold, FilterChip(
modifier = Modifier.weight(1f) onClick = { showAllTime = true },
label = {
Text("All Time", style = MaterialTheme.typography.bodySmall)
},
selected = showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
) )
)
// 7 Days button
FilterChip(
onClick = { showAllTime = false },
label = { Text("7 Days", style = MaterialTheme.typography.bodySmall) },
selected = !showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
// Scale selector dropdown // Scale selector dropdown
if (usedSystems.size > 1) { if (usedSystems.size > 1) {
@@ -169,7 +183,8 @@ fun ProgressChartCard(
onExpandedChange = { expanded = !expanded } onExpandedChange = { expanded = !expanded }
) { ) {
OutlinedTextField( OutlinedTextField(
value = when (selectedSystem) { value =
when (selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale" DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font" DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS" DifficultySystem.YDS -> "YDS"
@@ -177,9 +192,14 @@ fun ProgressChartCard(
}, },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = {
modifier = Modifier ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true) },
modifier =
Modifier.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable,
enabled = true
)
.width(120.dp), .width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium textStyle = MaterialTheme.typography.bodyMedium
) )
@@ -190,12 +210,14 @@ fun ProgressChartCard(
usedSystems.forEach { system -> usedSystems.forEach { system ->
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(when (system) { Text(
when (system) {
DifficultySystem.V_SCALE -> "V-Scale" DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font" DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS" DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom" DifficultySystem.CUSTOM -> "Custom"
}) }
)
}, },
onClick = { onClick = {
selectedSystem = system selectedSystem = system
@@ -210,77 +232,105 @@ fun ProgressChartCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale // Filter grade distribution data by selected scale and time period
val filteredProgressData = remember(progressData, selectedSystem) { val filteredGradeData =
progressData.filter { it.difficultySystem == selectedSystem } remember(gradeDistributionData, selectedSystem, showAllTime) {
val systemFiltered =
gradeDistributionData.filter {
it.difficultySystem == selectedSystem
} }
if (filteredProgressData.isNotEmpty()) { if (showAllTime) {
val chartData = remember(filteredProgressData) { systemFiltered
// Convert progress data to chart data points ordered by session } else {
filteredProgressData // Filter for last 7 days
.sortedBy { it.date } val sevenDaysAgo = LocalDateTime.now().minusDays(7)
.mapIndexed { index, p -> systemFiltered.filter { dataPoint ->
ChartDataPoint( try {
x = (index + 1).toFloat(), val attemptDate =
y = p.maxGradeNumeric.toFloat(), LocalDateTime.parse(
label = "Session ${index + 1}" dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (e: Exception) {
// If date parsing fails, include the data point
true
}
}
}
}
if (filteredGradeData.isNotEmpty()) {
// Group by grade and sum counts
val gradeGroups =
filteredGradeData
.groupBy { it.grade }
.mapValues { (_, dataPoints) -> dataPoints.sumOf { it.count } }
.map { (grade, count) ->
val firstDataPoint =
filteredGradeData.first { it.grade == grade }
BarChartDataPoint(
label = grade,
value = count,
gradeNumeric = firstDataPoint.gradeNumeric
) )
} }
}
LineChart( BarChart(data = gradeGroups, modifier = Modifier.fillMaxWidth().height(220.dp))
data = chartData,
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
"Successful climbs by ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column(
modifier = Modifier.fillMaxWidth().height(220.dp), modifier = Modifier.fillMaxWidth().height(220.dp),
xAxisFormatter = { value -> horizontalAlignment = Alignment.CenterHorizontally,
"S${value.toInt()}" // S1, S2, S3, etc. verticalArrangement = Arrangement.Center
}, ) {
yAxisFormatter = { value -> Icon(
numericToGrade(selectedSystem, value.toInt()) painter = painterResource(id = R.drawable.ic_mountains),
} contentDescription = "No data",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "X: session number, Y: max ${when(selectedSystem) { text = "No data available.",
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}} achieved",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No progress data available for ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}} system",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text(
text =
if (showAllTime)
"Complete some climbs to see your grade distribution!"
else "No climbs in the last 7 days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
} }
} }
} }
} }
@Composable @Composable
fun FavoriteGymCard( fun FavoriteGymCard(gymName: String, sessionCount: Int) {
gymName: String, Card(modifier = Modifier.fillMaxWidth()) {
sessionCount: Int Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Favorite Gym", text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -307,17 +357,9 @@ fun FavoriteGymCard(
} }
@Composable @Composable
fun RecentActivityCard( fun RecentActivityCard(recentSessions: Int) {
recentSessions: Int Card(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Recent Activity", text = "Recent Activity",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -327,7 +369,8 @@ fun RecentActivityCard(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = if (recentSessions > 0) { text =
if (recentSessions > 0) {
"You've had $recentSessions recent sessions" "You've had $recentSessions recent sessions"
} else { } else {
"No recent activity" "No recent activity"
@@ -338,43 +381,68 @@ fun RecentActivityCard(
} }
} }
data class ProgressDataPoint( data class GradeDistributionDataPoint(
val date: String, val date: String,
val maxGrade: String, val grade: String,
val maxGradeNumeric: Int, val gradeNumeric: Int,
val count: Int,
val climbType: ClimbType, val climbType: ClimbType,
val difficultySystem: DifficultySystem val difficultySystem: DifficultySystem
) )
fun calculateProgressOverTime( fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>, sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt> attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> { ): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) { if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList() return emptyList()
} }
val sessionProgress = sessions.mapNotNull { session -> // Get all successful attempts
val sessionAttempts = attempts.filter { it.sessionId == session.id } val successfulAttempts =
if (sessionAttempts.isEmpty()) return@mapNotNull null attempts.filter {
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct() it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
val attemptedProblems = problems.filter { it.id in attemptedProblemIds }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem ->
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade)
} }
if (highestGradeProblem != null) {
ProgressDataPoint( if (successfulAttempts.isEmpty()) {
date = session.date, return emptyList()
maxGrade = highestGradeProblem.difficulty.grade, }
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
climbType = highestGradeProblem.climbType, // Map attempts to problems and create grade distribution data
difficultySystem = highestGradeProblem.difficulty.system val gradeDistribution = mutableMapOf<String, GradeDistributionDataPoint>()
successfulAttempts.forEach { attempt ->
val problem = problems.find { it.id == attempt.problemId }
val session = sessions.find { it.id == attempt.sessionId }
if (problem != null && session != null) {
val key = "${problem.difficulty.system.name}-${problem.difficulty.grade}"
val existing = gradeDistribution[key]
if (existing != null) {
gradeDistribution[key] = existing.copy(count = existing.count + 1)
} else {
gradeDistribution[key] =
GradeDistributionDataPoint(
date =
attempt.timestamp
.toString(), // Use attempt timestamp for filtering
grade = problem.difficulty.grade,
gradeNumeric =
gradeToNumeric(
problem.difficulty.system,
problem.difficulty.grade
),
count = 1,
climbType = problem.climbType,
difficultySystem = problem.difficulty.system
) )
} else null
} }
return sessionProgress.sortedBy { it.date } }
}
return gradeDistribution.values.toList()
} }
fun gradeToNumeric(system: DifficultySystem, grade: String): Int { fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
@@ -460,84 +528,3 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
} }
} }
} }
fun numericToGrade(system: DifficultySystem, numeric: Int): String {
return when (system) {
DifficultySystem.V_SCALE -> {
when (numeric) {
0 -> "VB"
else -> "V$numeric"
}
}
DifficultySystem.FONT -> {
when (numeric) {
3 -> "3"
4 -> "4A"
5 -> "4B"
6 -> "4C"
7 -> "5A"
8 -> "5B"
9 -> "5C"
10 -> "6A"
11 -> "6A+"
12 -> "6B"
13 -> "6B+"
14 -> "6C"
15 -> "6C+"
16 -> "7A"
17 -> "7A+"
18 -> "7B"
19 -> "7B+"
20 -> "7C"
21 -> "7C+"
22 -> "8A"
23 -> "8A+"
24 -> "8B"
25 -> "8B+"
26 -> "8C"
27 -> "8C+"
else -> numeric.toString()
}
}
DifficultySystem.YDS -> {
when (numeric) {
50 -> "5.0"
51 -> "5.1"
52 -> "5.2"
53 -> "5.3"
54 -> "5.4"
55 -> "5.5"
56 -> "5.6"
57 -> "5.7"
58 -> "5.8"
59 -> "5.9"
60 -> "5.10a"
61 -> "5.10b"
62 -> "5.10c"
63 -> "5.10d"
64 -> "5.11a"
65 -> "5.11b"
66 -> "5.11c"
67 -> "5.11d"
68 -> "5.12a"
69 -> "5.12b"
70 -> "5.12c"
71 -> "5.12d"
72 -> "5.13a"
73 -> "5.13b"
74 -> "5.13c"
75 -> "5.13d"
76 -> "5.14a"
77 -> "5.14b"
78 -> "5.14c"
79 -> "5.14d"
80 -> "5.15a"
81 -> "5.15b"
82 -> "5.15c"
83 -> "5.15d"
else -> numeric.toString()
}
}
DifficultySystem.CUSTOM -> numeric.toString()
}
}

View File

@@ -3,13 +3,13 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.Add import androidx.compose.material.icons.filled.Add
@@ -56,9 +56,7 @@ fun EditAttemptDialog(
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(24.dp),
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
Text( Text(
@@ -130,8 +128,8 @@ fun EditAttemptDialog(
) { ) {
AttemptResult.entries.forEach { result -> AttemptResult.entries.forEach { result ->
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.selectable( .selectable(
selected = selectedResult == result, selected = selectedResult == result,
onClick = { selectedResult = result }, onClick = { selectedResult = result },
@@ -140,15 +138,16 @@ fun EditAttemptDialog(
.padding(8.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( RadioButton(selected = selectedResult == result, onClick = null)
selected = selectedResult == result,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = when (result) { text =
when (result) {
AttemptResult.NO_PROGRESS -> "No Progress" AttemptResult.NO_PROGRESS -> "No Progress"
else -> result.name.lowercase().replaceFirstChar { it.uppercase() } else ->
result.name.lowercase().replaceFirstChar {
it.uppercase()
}
}, },
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@@ -200,9 +199,7 @@ fun EditAttemptDialog(
}, },
enabled = selectedProblem != null, enabled = selectedProblem != null,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) { Text("Update") }
Text("Update")
}
} }
} }
} }
@@ -256,7 +253,10 @@ fun SessionDetailScreen(
title = { Text("Session Details") }, title = { Text("Session Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
@@ -267,7 +267,10 @@ fun SessionDetailScreen(
isGeneratingShare = true isGeneratingShare = true
viewModel.viewModelScope.launch { viewModel.viewModelScope.launch {
val shareFile = val shareFile =
viewModel.generateSessionShareCard(context, sessionId) viewModel.generateSessionShareCard(
context,
sessionId
)
isGeneratingShare = false isGeneratingShare = false
shareFile?.let { file -> shareFile?.let { file ->
viewModel.shareSessionCard(context, file) viewModel.shareSessionCard(context, file)
@@ -290,16 +293,22 @@ fun SessionDetailScreen(
} }
} }
// Show stop icon for active sessions, delete icon for completed sessions // Show stop icon for active sessions, delete icon for completed
// sessions
if (session?.status == SessionStatus.ACTIVE) { if (session?.status == SessionStatus.ACTIVE) {
IconButton(onClick = { IconButton(
onClick = {
session.let { s -> session.let { s ->
viewModel.endSession(context, s.id) viewModel.endSession(context, s.id)
onNavigateBack() onNavigateBack()
} }
}) { }
) {
Icon( Icon(
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onSurface), imageVector =
CustomIcons.Stop(
MaterialTheme.colorScheme.onSurface
),
contentDescription = "Stop Session" contentDescription = "Stop Session"
) )
} }
@@ -372,8 +381,10 @@ fun SessionDetailScreen(
) { ) {
Text( Text(
text = text =
if (session?.duration != null) "Completed" else "In Progress", if (session?.duration != null) "Completed"
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), else "In Progress",
modifier =
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = color =
if (session?.duration != null) if (session?.duration != null)
@@ -428,55 +439,6 @@ fun SessionDetailScreen(
value = completedProblems.size.toString() value = completedProblems.size.toString()
) )
} }
// Show grade range(s) with better layout
val grades = attemptedProblems.map { it.difficulty }
if (grades.isNotEmpty()) {
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderRange = if (boulderProblems.isNotEmpty()) {
val boulderGrades = boulderProblems.map { it.difficulty }
val sorted = boulderGrades.sortedWith { a, b -> a.compareTo(b) }
"${sorted.first().grade} - ${sorted.last().grade}"
} else null
val ropeRange = if (ropeProblems.isNotEmpty()) {
val ropeGrades = ropeProblems.map { it.difficulty }
val sorted = ropeGrades.sortedWith { a, b -> a.compareTo(b) }
"${sorted.first().grade} - ${sorted.last().grade}"
} else null
if (boulderRange != null && ropeRange != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(label = "Boulder Range", value = boulderRange)
StatItem(label = "Rope Range", value = ropeRange)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
StatItem(
label = "Grade Range",
value = boulderRange ?: ropeRange ?: "N/A"
)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
StatItem(
label = "Grade Range",
value = "N/A"
)
}
}
} }
} }
} }
@@ -518,7 +480,9 @@ fun SessionDetailScreen(
SessionAttemptCard( SessionAttemptCard(
attempt = attempt, attempt = attempt,
problem = problem, problem = problem,
onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit }, onEditAttempt = { attemptToEdit ->
showEditAttemptDialog = attemptToEdit
},
onDeleteAttempt = { attemptToDelete -> onDeleteAttempt = { attemptToDelete ->
viewModel.deleteAttempt(attemptToDelete) viewModel.deleteAttempt(attemptToDelete)
}, },
@@ -539,7 +503,8 @@ fun SessionDetailScreen(
Text("Are you sure you want to delete this session?") Text("Are you sure you want to delete this session?")
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "This will also delete all attempts associated with this session.", text =
"This will also delete all attempts associated with this session.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -554,9 +519,7 @@ fun SessionDetailScreen(
} }
showDeleteDialog = false showDeleteDialog = false
} }
) { ) { Text("Delete", color = MaterialTheme.colorScheme.error) }
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
@@ -633,7 +596,10 @@ fun ProblemDetailScreen(
title = { Text("Problem Details") }, title = { Text("Problem Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
@@ -727,15 +693,6 @@ fun ProblemDetailScreen(
} }
} }
problem?.setter?.let { setter ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Set by: $setter",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (problem?.tags?.isNotEmpty() == true) { if (problem?.tags?.isNotEmpty() == true) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -832,7 +789,8 @@ fun ProblemDetailScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Start a session and track your attempts on this problem!", text =
"Start a session and track your attempts on this problem!",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -858,7 +816,8 @@ fun ProblemDetailScreen(
Text("Are you sure you want to delete this problem?") Text("Are you sure you want to delete this problem?")
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "This will also delete all attempts associated with this problem.", text =
"This will also delete all attempts associated with this problem.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -873,9 +832,7 @@ fun ProblemDetailScreen(
} }
showDeleteDialog = false showDeleteDialog = false
} }
) { ) { Text("Delete", color = MaterialTheme.colorScheme.error) }
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
@@ -929,7 +886,10 @@ fun GymDetailScreen(
title = { Text(gym?.name ?: "Gym Details") }, title = { Text(gym?.name ?: "Gym Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
} }
}, },
actions = { actions = {
@@ -947,9 +907,7 @@ fun GymDetailScreen(
Box( Box(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) { Text("Gym not found") }
Text("Gym not found")
}
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),
@@ -1025,7 +983,6 @@ fun GymDetailScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -1097,10 +1054,8 @@ fun GymDetailScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Show recent problems (limit to 5) // Show recent problems (limit to 5)
problems problems.sortedByDescending { it.createdAt }.take(5).forEach {
.sortedByDescending { it.createdAt } problem ->
.take(5)
.forEach { problem ->
val problemAttempts = val problemAttempts =
gymAttempts.filter { it.problemId == problem.id } gymAttempts.filter { it.problemId == problem.id }
val problemSuccessful = val problemSuccessful =
@@ -1114,21 +1069,30 @@ fun GymDetailScreen(
Card( Card(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.clickable { onNavigateToProblemDetail(problem.id) }, .clickable {
onNavigateToProblemDetail(
problem.id
)
},
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor =
MaterialTheme.colorScheme
.surface
), ),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation =
CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
text = problem.name ?: "Unnamed Problem", text = problem.name
?: "Unnamed Problem",
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1142,7 +1106,9 @@ fun GymDetailScreen(
Icon( Icon(
Icons.Default.Check, Icons.Default.Check,
contentDescription = "Completed", contentDescription = "Completed",
tint = MaterialTheme.colorScheme.primary tint =
MaterialTheme.colorScheme
.primary
) )
} }
} }
@@ -1180,25 +1146,30 @@ fun GymDetailScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Show recent sessions (limit to 3) // Show recent sessions (limit to 3)
sessions sessions.sortedByDescending { it.date }.take(3).forEach { session ->
.sortedByDescending { it.date }
.take(3)
.forEach { session ->
val sessionAttempts = val sessionAttempts =
gymAttempts.filter { it.sessionId == session.id } gymAttempts.filter { it.sessionId == session.id }
Card( Card(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.clickable { onNavigateToSessionDetail(session.id) }, .clickable {
onNavigateToSessionDetail(
session.id
)
},
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor =
MaterialTheme.colorScheme
.surface
), ),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation =
CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -1210,26 +1181,27 @@ fun GymDetailScreen(
) { ) {
Text( Text(
text = text =
if ( if (session.status ==
session.status == SessionStatus
SessionStatus.ACTIVE .ACTIVE
) )
"Active Session" "Active Session"
else "Session", else "Session",
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
if ( if (session.status == SessionStatus.ACTIVE
session.status == SessionStatus.ACTIVE
) { ) {
Badge( Badge(
containerColor = containerColor =
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.primary .primary
) { ) {
Text( Text(
"ACTIVE", "ACTIVE",
style = style =
MaterialTheme.typography MaterialTheme
.typography
.labelSmall .labelSmall
) )
} }
@@ -1248,7 +1220,8 @@ fun GymDetailScreen(
DateTimeFormatter.ofPattern( DateTimeFormatter.ofPattern(
"MMM dd, yyyy" "MMM dd, yyyy"
) )
) ?: session.date )
?: session.date
Text( Text(
"$formattedDate${sessionAttempts.size} attempts" "$formattedDate${sessionAttempts.size} attempts"
@@ -1259,7 +1232,8 @@ fun GymDetailScreen(
Text( Text(
text = "${duration}min", text = "${duration}min",
style = style =
MaterialTheme.typography.bodySmall, MaterialTheme.typography
.bodySmall,
color = color =
MaterialTheme.colorScheme MaterialTheme.colorScheme
.onSurfaceVariant .onSurfaceVariant
@@ -1339,9 +1313,7 @@ fun GymDetailScreen(
} }
showDeleteDialog = false showDeleteDialog = false
} }
) { ) { Text("Delete", color = MaterialTheme.colorScheme.error) }
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
@@ -1406,23 +1378,24 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) {
fun AttemptResultBadge(result: AttemptResult) { fun AttemptResultBadge(result: AttemptResult) {
val backgroundColor = val backgroundColor =
when (result) { when (result) {
AttemptResult.SUCCESS, AttemptResult.SUCCESS, AttemptResult.FLASH ->
AttemptResult.FLASH -> MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
} }
val textColor = val textColor =
when (result) { when (result) {
AttemptResult.SUCCESS, AttemptResult.SUCCESS, AttemptResult.FLASH ->
AttemptResult.FLASH -> MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
Surface(color = backgroundColor, shape = RoundedCornerShape(12.dp)) { Surface(color = backgroundColor, shape = RoundedCornerShape(12.dp)) {
Text( Text(
text = when (result) { text =
when (result) {
AttemptResult.NO_PROGRESS -> "No Progress" AttemptResult.NO_PROGRESS -> "No Progress"
else -> result.name.lowercase().replaceFirstChar { it.uppercase() } else -> result.name.lowercase().replaceFirstChar { it.uppercase() }
}, },
@@ -1445,9 +1418,7 @@ fun SessionAttemptCard(
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().clickable { onAttemptClick() },
.fillMaxWidth()
.clickable { onAttemptClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
@@ -1530,9 +1501,7 @@ fun SessionAttemptCard(
onDeleteAttempt(attempt) onDeleteAttempt(attempt)
showDeleteDialog = false showDeleteDialog = false
} }
) { ) { Text("Delete", color = MaterialTheme.colorScheme.error) }
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
@@ -1578,8 +1547,7 @@ fun EnhancedAddAttemptDialog(
// Auto-select climb type if there's only one available // Auto-select climb type if there's only one available
LaunchedEffect(gym.supportedClimbTypes) { LaunchedEffect(gym.supportedClimbTypes) {
if ( if (gym.supportedClimbTypes.size == 1 &&
gym.supportedClimbTypes.size == 1 &&
selectedClimbType != gym.supportedClimbTypes.first() selectedClimbType != gym.supportedClimbTypes.first()
) { ) {
selectedClimbType = gym.supportedClimbTypes.first() selectedClimbType = gym.supportedClimbTypes.first()
@@ -1598,8 +1566,7 @@ fun EnhancedAddAttemptDialog(
selectedDifficultySystem !in availableSystems -> { selectedDifficultySystem !in availableSystems -> {
selectedDifficultySystem = selectedDifficultySystem =
availableSystems.firstOrNull() availableSystems.firstOrNull()
?: gym.difficultySystems.firstOrNull() ?: gym.difficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM
?: DifficultySystem.CUSTOM
} }
// If there's only one available system, auto-select it // If there's only one available system, auto-select it
availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> { availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> {
@@ -1617,16 +1584,9 @@ fun EnhancedAddAttemptDialog(
} }
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Card( Card(modifier = Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.9f).padding(16.dp)) {
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f)
.padding(16.dp)
) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(24.dp),
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
Text( Text(
@@ -1655,7 +1615,8 @@ fun EnhancedAddAttemptDialog(
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy( MaterialTheme.colorScheme
.surfaceVariant.copy(
alpha = 0.5f alpha = 0.5f
) )
), ),
@@ -1668,7 +1629,9 @@ fun EnhancedAddAttemptDialog(
Text( Text(
text = "No active problems in this gym", text = "No active problems in this gym",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color =
MaterialTheme.colorScheme
.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -1691,46 +1654,65 @@ fun EnhancedAddAttemptDialog(
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isSelected) if (isSelected)
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.primaryContainer .primaryContainer
else else
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.surfaceVariant, .surfaceVariant,
), ),
border = border =
if (isSelected) if (isSelected)
BorderStroke( BorderStroke(
2.dp, 2.dp,
MaterialTheme.colorScheme.primary MaterialTheme
.colorScheme
.primary
) )
else null, else null,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
text = problem.name ?: "Unnamed Problem", text = problem.name
style = MaterialTheme.typography.bodyLarge, ?: "Unnamed Problem",
style =
MaterialTheme.typography
.bodyLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = color =
if (isSelected) if (isSelected)
MaterialTheme.colorScheme.onSurface MaterialTheme
.colorScheme
.onSurface
else else
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.onSurfaceVariant .onSurfaceVariant
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text =
"${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography
.bodyMedium,
color = color =
if (isSelected) if (isSelected)
MaterialTheme.colorScheme.onSurface MaterialTheme
.copy(alpha = 0.8f) .colorScheme
.onSurface.copy(
alpha = 0.8f
)
else else
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.onSurfaceVariant .onSurfaceVariant
.copy(alpha = 0.7f), .copy(
alpha =
0.7f
),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
@@ -1742,9 +1724,7 @@ fun EnhancedAddAttemptDialog(
OutlinedButton( OutlinedButton(
onClick = { showCreateProblem = true }, onClick = { showCreateProblem = true },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) { Text("Create New Problem") }
Text("Create New Problem")
}
} }
} }
} else { } else {
@@ -1779,8 +1759,10 @@ fun EnhancedAddAttemptDialog(
singleLine = true, singleLine = true,
colors = colors =
OutlinedTextFieldDefaults.colors( OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor =
unfocusedBorderColor = MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.primary,
unfocusedBorderColor =
MaterialTheme.colorScheme.outline
) )
) )
@@ -1806,10 +1788,12 @@ fun EnhancedAddAttemptDialog(
colors = colors =
FilterChipDefaults.filterChipColors( FilterChipDefaults.filterChipColors(
selectedContainerColor = selectedContainerColor =
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.primaryContainer, .primaryContainer,
selectedLabelColor = selectedLabelColor =
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.onPrimaryContainer .onPrimaryContainer
) )
) )
@@ -1846,10 +1830,12 @@ fun EnhancedAddAttemptDialog(
colors = colors =
FilterChipDefaults.filterChipColors( FilterChipDefaults.filterChipColors(
selectedContainerColor = selectedContainerColor =
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.primaryContainer, .primaryContainer,
selectedLabelColor = selectedLabelColor =
MaterialTheme.colorScheme MaterialTheme
.colorScheme
.onPrimaryContainer .onPrimaryContainer
) )
) )
@@ -1862,36 +1848,51 @@ fun EnhancedAddAttemptDialog(
value = newProblemGrade, value = newProblemGrade,
onValueChange = { newValue -> onValueChange = { newValue ->
// Only allow integers for custom scales // Only allow integers for custom scales
if (newValue.isEmpty() || newValue.all { it.isDigit() }) { if (newValue.isEmpty() ||
newValue.all { it.isDigit() }
) {
newProblemGrade = newValue newProblemGrade = newValue
} }
}, },
label = { Text("Grade *") }, label = { Text("Grade *") },
placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") }, placeholder = {
Text("Enter numeric grade (e.g. 5, 10, 15)")
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
colors = colors =
OutlinedTextFieldDefaults.colors( OutlinedTextFieldDefaults.colors(
focusedBorderColor = focusedBorderColor =
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme
.primary,
unfocusedBorderColor = unfocusedBorderColor =
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme
.outline
), ),
isError = newProblemGrade.isBlank(), isError = newProblemGrade.isBlank(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number
),
supportingText = supportingText =
if (newProblemGrade.isBlank()) { if (newProblemGrade.isBlank()) {
{ {
Text( Text(
"Grade is required", "Grade is required",
color = MaterialTheme.colorScheme.error color =
MaterialTheme
.colorScheme
.error
) )
} }
} else { } else {
{ {
Text( Text(
"Custom grades must be whole numbers", "Custom grades must be whole numbers",
color = MaterialTheme.colorScheme.onSurfaceVariant color =
MaterialTheme
.colorScheme
.onSurfaceVariant
) )
} }
} }
@@ -1919,14 +1920,24 @@ fun EnhancedAddAttemptDialog(
colors = colors =
ExposedDropdownMenuDefaults ExposedDropdownMenuDefaults
.outlinedTextFieldColors(), .outlinedTextFieldColors(),
modifier = Modifier.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true).fillMaxWidth(), modifier =
Modifier.menuAnchor(
androidx.compose.material3
.MenuAnchorType
.PrimaryNotEditable,
enabled = true
)
.fillMaxWidth(),
isError = newProblemGrade.isBlank(), isError = newProblemGrade.isBlank(),
supportingText = supportingText =
if (newProblemGrade.isBlank()) { if (newProblemGrade.isBlank()) {
{ {
Text( Text(
"Grade is required", "Grade is required",
color = MaterialTheme.colorScheme.error color =
MaterialTheme
.colorScheme
.error
) )
} }
} else null } else null
@@ -1965,9 +1976,8 @@ fun EnhancedAddAttemptDialog(
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy( MaterialTheme.colorScheme.surfaceVariant
alpha = 0.3f .copy(alpha = 0.3f)
)
) )
) { ) {
Column(modifier = Modifier.padding(12.dp).selectableGroup()) { Column(modifier = Modifier.padding(12.dp).selectableGroup()) {
@@ -1977,8 +1987,12 @@ fun EnhancedAddAttemptDialog(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.selectable( .selectable(
selected = selectedResult == result, selected =
onClick = { selectedResult = result }, selectedResult ==
result,
onClick = {
selectedResult = result
},
role = Role.RadioButton role = Role.RadioButton
) )
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
@@ -1989,18 +2003,22 @@ fun EnhancedAddAttemptDialog(
colors = colors =
RadioButtonDefaults.colors( RadioButtonDefaults.colors(
selectedColor = selectedColor =
MaterialTheme.colorScheme.primary MaterialTheme
.colorScheme
.primary
) )
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = text =
result.name.lowercase().replaceFirstChar { result.name.lowercase()
.replaceFirstChar {
it.uppercase() it.uppercase()
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = fontWeight =
if (selectedResult == result) FontWeight.Medium if (selectedResult == result)
FontWeight.Medium
else FontWeight.Normal, else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@@ -2028,8 +2046,10 @@ fun EnhancedAddAttemptDialog(
singleLine = true, singleLine = true,
colors = colors =
OutlinedTextFieldDefaults.colors( OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor =
unfocusedBorderColor = MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.primary,
unfocusedBorderColor =
MaterialTheme.colorScheme.outline
) )
) )
@@ -2045,8 +2065,10 @@ fun EnhancedAddAttemptDialog(
maxLines = 4, maxLines = 4,
colors = colors =
OutlinedTextFieldDefaults.colors( OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor =
unfocusedBorderColor = MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.primary,
unfocusedBorderColor =
MaterialTheme.colorScheme.outline
) )
) )
} }
@@ -2059,11 +2081,10 @@ fun EnhancedAddAttemptDialog(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = colors =
ButtonDefaults.outlinedButtonColors( ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface contentColor =
MaterialTheme.colorScheme.onSurface
) )
) { ) { Text("Cancel", fontWeight = FontWeight.Medium) }
Text("Cancel", fontWeight = FontWeight.Medium)
}
Button( Button(
onClick = { onClick = {
@@ -2072,23 +2093,34 @@ fun EnhancedAddAttemptDialog(
if (newProblemGrade.isNotBlank()) { if (newProblemGrade.isNotBlank()) {
val difficulty = val difficulty =
DifficultyGrade( DifficultyGrade(
system = selectedDifficultySystem, system =
selectedDifficultySystem,
grade = newProblemGrade, grade = newProblemGrade,
numericValue = numericValue =
when (selectedDifficultySystem) { when (selectedDifficultySystem
DifficultySystem.V_SCALE -> ) {
DifficultySystem
.V_SCALE ->
newProblemGrade newProblemGrade
.removePrefix("V") .removePrefix(
.toIntOrNull() ?: 0 "V"
)
.toIntOrNull()
?: 0
else -> else ->
newProblemGrade.hashCode() % 100 newProblemGrade
.hashCode() %
100
} }
) )
val newProblem = val newProblem =
Problem.create( Problem.create(
gymId = gym.id, gymId = gym.id,
name = newProblemName.ifBlank { null }, name =
newProblemName.ifBlank {
null
},
climbType = selectedClimbType, climbType = selectedClimbType,
difficulty = difficulty difficulty = difficulty
) )
@@ -2101,7 +2133,10 @@ fun EnhancedAddAttemptDialog(
sessionId = session.id, sessionId = session.id,
problemId = newProblem.id, problemId = newProblem.id,
result = selectedResult, result = selectedResult,
highestHold = highestHold.ifBlank { null }, highestHold =
highestHold.ifBlank {
null
},
notes = notes.ifBlank { null } notes = notes.ifBlank { null }
) )
onAttemptAdded(attempt) onAttemptAdded(attempt)
@@ -2114,7 +2149,10 @@ fun EnhancedAddAttemptDialog(
sessionId = session.id, sessionId = session.id,
problemId = problem.id, problemId = problem.id,
result = selectedResult, result = selectedResult,
highestHold = highestHold.ifBlank { null }, highestHold =
highestHold.ifBlank {
null
},
notes = notes.ifBlank { null } notes = notes.ifBlank { null }
) )
onAttemptAdded(attempt) onAttemptAdded(attempt)
@@ -2127,15 +2165,13 @@ fun EnhancedAddAttemptDialog(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor =
MaterialTheme.colorScheme.primary,
disabledContainerColor = disabledContainerColor =
MaterialTheme.colorScheme.onSurface.copy( MaterialTheme.colorScheme.onSurface
alpha = 0.12f .copy(alpha = 0.12f)
) )
) ) { Text("Add", fontWeight = FontWeight.Medium) }
) {
Text("Add", fontWeight = FontWeight.Medium)
}
} }
} }
} }

View File

@@ -21,10 +21,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProblemsScreen( fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit
) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
@@ -36,17 +33,19 @@ fun ProblemsScreen(
var selectedGym by remember { mutableStateOf<Gym?>(null) } var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters // Apply filters
val filteredProblems = problems.filter { problem -> val filteredProblems =
problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch climbTypeMatch && gymMatch
} }
Column( // Separate active and inactive problems
modifier = Modifier val activeProblems = filteredProblems.filter { it.isActive }
.fillMaxSize() val inactiveProblems = filteredProblems.filter { !it.isActive }
.padding(16.dp) val sortedProblems = activeProblems + inactiveProblems
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -69,12 +68,8 @@ fun ProblemsScreen(
// Filters Section // Filters Section
if (problems.isNotEmpty()) { if (problems.isNotEmpty()) {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Filters", text = "Filters",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -92,9 +87,7 @@ fun ProblemsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { item {
FilterChip( FilterChip(
onClick = { selectedClimbType = null }, onClick = { selectedClimbType = null },
@@ -122,9 +115,7 @@ fun ProblemsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { item {
FilterChip( FilterChip(
onClick = { selectedGym = null }, onClick = { selectedGym = null },
@@ -145,7 +136,8 @@ fun ProblemsScreen(
if (selectedClimbType != null || selectedGym != null) { if (selectedClimbType != null || selectedGym != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Showing ${filteredProblems.size} of ${problems.size} problems", text =
"Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -158,13 +150,17 @@ fun ProblemsScreen(
if (filteredProblems.isEmpty()) { if (filteredProblems.isEmpty()) {
EmptyStateMessage( EmptyStateMessage(
title = if (problems.isEmpty()) { title =
if (problems.isEmpty()) {
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet" if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
} else { } else {
"No Problems Match Filters" "No Problems Match Filters"
}, },
message = if (problems.isEmpty()) { message =
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!" if (problems.isEmpty()) {
if (gyms.isEmpty())
"Add a gym first to start tracking problems and routes!"
else "Start tracking your favorite problems and routes!"
} else { } else {
"Try adjusting your filters to see more problems." "Try adjusting your filters to see more problems."
}, },
@@ -173,7 +169,7 @@ fun ProblemsScreen(
) )
} else { } else {
LazyColumn { LazyColumn {
items(filteredProblems) { problem -> items(sortedProblems) { problem ->
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
@@ -182,6 +178,10 @@ fun ProblemsScreen(
selectedImagePaths = imagePaths selectedImagePaths = imagePaths
selectedImageIndex = index selectedImageIndex = index
showImageViewer = true showImageViewer = true
},
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem)
} }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -206,17 +206,11 @@ fun ProblemCard(
problem: Problem, problem: Problem,
gymName: String, gymName: String,
onClick: () -> Unit, onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null onImageClick: ((List<String>, Int) -> Unit)? = null,
) { onToggleActive: (() -> Unit)? = null
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) { ) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -226,13 +220,19 @@ fun ProblemCard(
Text( Text(
text = problem.name ?: "Unnamed Problem", text = problem.name ?: "Unnamed Problem",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color =
if (problem.isActive) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
) )
Text( Text(
text = gymName, text = gymName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (problem.isActive) 1f else 0.6f
)
) )
} }
@@ -280,20 +280,40 @@ fun ProblemCard(
ImageDisplay( ImageDisplay(
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
imageSize = 60, imageSize = 60,
onImageClick = { index -> onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
onImageClick?.invoke(problem.imagePaths, index)
}
) )
} }
if (!problem.isActive) { if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Inactive", text = "Reset / No Longer Set",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
)
}
// Toggle active button
if (onToggleActive != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onToggleActive,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor =
if (problem.isActive)
MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (problem.isActive) "Mark as Reset" else "Mark as Active",
style = MaterialTheme.typography.bodySmall
) )
} }
} }
} }
} }
}

View File

@@ -324,12 +324,17 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value =
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data with images exported successfully" message =
"Export complete! Your climbing data and images have been saved."
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =

View File

@@ -5,14 +5,14 @@ import android.content.Intent
import android.graphics.* import android.graphics.*
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
object SessionShareUtils { object SessionShareUtils {
@@ -33,10 +33,7 @@ object SessionShareUtils {
attempts: List<Attempt>, attempts: List<Attempt>,
problems: List<Problem> problems: List<Problem>
): SessionStats { ): SessionStats {
val successfulResults = listOf( val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
AttemptResult.SUCCESS,
AttemptResult.FLASH
)
val successfulAttempts = attempts.filter { it.result in successfulResults } val successfulAttempts = attempts.filter { it.result in successfulResults }
val uniqueProblems = attempts.map { it.problemId }.distinct() val uniqueProblems = attempts.map { it.problemId }.distinct()
@@ -52,8 +49,10 @@ object SessionShareUtils {
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope") val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display // Combine averages for display
val averageGrade = when { val averageGrade =
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage" when {
boulderAverage != null && ropeAverage != null ->
"$boulderAverage / $ropeAverage"
boulderAverage != null -> boulderAverage boulderAverage != null -> boulderAverage
ropeAverage != null -> ropeAverage ropeAverage != null -> ropeAverage
else -> null else -> null
@@ -65,7 +64,8 @@ object SessionShareUtils {
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE } val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder) val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope) val topRope = highestGradeForProblems(completedRope)
val topGrade = when { val topGrade =
when {
topBoulder != null && topRope != null -> "$topBoulder / $topRope" topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topBoulder != null -> topBoulder topBoulder != null -> topBoulder
topRope != null -> topRope topRope != null -> topRope
@@ -73,14 +73,17 @@ object SessionShareUtils {
} }
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 {
when (it.result) { when (it.result) {
AttemptResult.FLASH -> 3 AttemptResult.FLASH -> 3
AttemptResult.SUCCESS -> 2 AttemptResult.SUCCESS -> 2
AttemptResult.FALL -> 1 AttemptResult.FALL -> 1
else -> 0 else -> 0
} }
}?.result }
?.result
return SessionStats( return SessionStats(
totalAttempts = attempts.size, totalAttempts = attempts.size,
@@ -109,7 +112,8 @@ object SessionShareUtils {
problemsBySystem.forEach { (system, systemProblems) -> problemsBySystem.forEach { (system, systemProblems) ->
when (system) { when (system) {
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
systemProblems.mapNotNull { problem ->
when { when {
problem.difficulty.grade == "VB" -> 0 problem.difficulty.grade == "VB" -> 0
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
@@ -121,8 +125,10 @@ object SessionShareUtils {
} }
} }
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7) systemProblems.mapNotNull { problem ->
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" ->
// 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
} }
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
@@ -131,7 +137,8 @@ object SessionShareUtils {
} }
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
systemProblems.mapNotNull { problem ->
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
val grade = problem.difficulty.grade val grade = problem.difficulty.grade
if (grade.startsWith("5.")) { if (grade.startsWith("5.")) {
@@ -145,8 +152,12 @@ object SessionShareUtils {
} }
DifficultySystem.CUSTOM -> { DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values // For custom systems, try to extract numeric values
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() systemProblems.mapNotNull { problem ->
problem.difficulty
.grade
.filter { it.isDigit() || it == '.' || it == '-' }
.toDoubleOrNull()
} }
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average() val avg = gradeValues.average()
@@ -178,18 +189,17 @@ object SessionShareUtils {
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable( val gradientDrawable =
GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM, GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf( intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt())
"#667eea".toColorInt(),
"#764ba2".toColorInt()
)
) )
gradientDrawable.setBounds(0, 0, width, height) gradientDrawable.setBounds(0, 0, width, height)
gradientDrawable.draw(canvas) gradientDrawable.draw(canvas)
// Setup paint objects // Setup paint objects
val titlePaint = Paint().apply { val titlePaint =
Paint().apply {
color = Color.WHITE color = Color.WHITE
textSize = 72f textSize = 72f
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
@@ -197,7 +207,8 @@ object SessionShareUtils {
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
val subtitlePaint = Paint().apply { val subtitlePaint =
Paint().apply {
color = "#E8E8E8".toColorInt() color = "#E8E8E8".toColorInt()
textSize = 48f textSize = 48f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
@@ -205,7 +216,8 @@ object SessionShareUtils {
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
val statLabelPaint = Paint().apply { val statLabelPaint =
Paint().apply {
color = "#B8B8B8".toColorInt() color = "#B8B8B8".toColorInt()
textSize = 36f textSize = 36f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
@@ -213,7 +225,8 @@ object SessionShareUtils {
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
val statValuePaint = Paint().apply { val statValuePaint =
Paint().apply {
color = Color.WHITE color = Color.WHITE
textSize = 64f textSize = 64f
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
@@ -221,7 +234,8 @@ object SessionShareUtils {
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
val cardPaint = Paint().apply { val cardPaint =
Paint().apply {
color = "#40FFFFFF".toColorInt() color = "#40FFFFFF".toColorInt()
isAntiAlias = true isAntiAlias = true
} }
@@ -252,43 +266,116 @@ object SessionShareUtils {
// Left column stats // Left column stats
var leftY = statsStartY var leftY = statsStartY
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Attempts",
stats.totalAttempts.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Problems",
stats.uniqueProblemsAttempted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Duration",
stats.sessionDuration,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
// Right column stats // Right column stats
var rightY = statsStartY var rightY = statsStartY
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
rightY += 120f canvas,
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) width - columnWidth / 2f,
rightY,
"Completed",
stats.uniqueProblemsCompleted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightY += 120f rightY += 120f
var rightYAfter = rightY var rightYAfter = rightY
stats.topGrade?.let { grade -> stats.topGrade?.let { grade ->
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Top Grade",
grade,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightYAfter += 120f rightYAfter += 120f
} }
// Grade range(s) // Grade range(s)
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER }) val boulderRange =
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE }) 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 val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) { if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items // Two evenly spaced items
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth) 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) { } else if (boulderRange != null || ropeRange != null) {
// Single centered item // Single centered item
val singleRange = boulderRange ?: ropeRange ?: "" val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f) drawStatItemFitting(
canvas,
width / 2f,
rangesY,
"Grade Range",
singleRange,
statLabelPaint,
statValuePaint,
width - 200f
)
} }
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint =
Paint().apply {
color = "#80FFFFFF".toColorInt() color = "#80FFFFFF".toColorInt()
textSize = 32f textSize = 32f
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
@@ -334,7 +421,8 @@ object SessionShareUtils {
} }
/** /**
* Draws a stat item while fitting the value text to a max width by reducing text size if needed. * Draws a stat item while fitting the value text to a max width by reducing text size if
* needed.
*/ */
private fun drawStatItemFitting( private fun drawStatItemFitting(
canvas: Canvas, canvas: Canvas,
@@ -368,8 +456,6 @@ object SessionShareUtils {
return "${sorted.first().grade} - ${sorted.last().grade}" return "${sorted.first().grade} - ${sorted.last().grade}"
} }
private fun formatSessionDate(dateString: String): String { private fun formatSessionDate(dateString: String): String {
return try { return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@@ -383,17 +469,22 @@ object SessionShareUtils {
fun shareSessionCard(context: Context, imageFile: File) { fun shareSessionCard(context: Context, imageFile: File) {
try { try {
val uri = FileProvider.getUriForFile( val uri =
FileProvider.getUriForFile(
context, context,
"${context.packageName}.fileprovider", "${context.packageName}.fileprovider",
imageFile imageFile
) )
val shareIntent = Intent().apply { val shareIntent =
Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "image/png" type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! 🧗‍♀️ #OpenClimb") putExtra(
Intent.EXTRA_TEXT,
"Check out my climbing session! 🧗‍♀️ #OpenClimb"
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
@@ -406,16 +497,18 @@ object SessionShareUtils {
} }
/** /**
* Returns the highest grade string among the given problems, respecting their difficulty system. * Returns the highest grade string among the given problems, respecting their difficulty
* system.
*/ */
private fun highestGradeForProblems(problems: List<Problem>): String? { private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null if (problems.isEmpty()) return null
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade return problems
.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }
?.difficulty
?.grade
} }
/** /** Produces a comparable numeric rank for grades across supported systems. */
* Produces a comparable numeric rank for grades across supported systems.
*/
private fun gradeRank(system: DifficultySystem, grade: String): Double { private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) { return when (system) {
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
@@ -424,7 +517,8 @@ object SessionShareUtils {
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades() val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase()) val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d // Parse 5.X with optional letter a-d
@@ -434,7 +528,8 @@ object SessionShareUtils {
val numberPart = tail.takeWhile { it.isDigit() || it == '.' } val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull() val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0 val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight = when (letterPart) { val letterWeight =
when (letterPart) {
'a' -> 0.0 'a' -> 0.0
'b' -> 0.1 'b' -> 0.1
'c' -> 0.2 'c' -> 0.2

View File

@@ -1,7 +1,8 @@
package com.atridad.openclimb.utils package com.atridad.openclimb.utils
import android.content.Context import android.content.Context
import kotlinx.serialization.json.Json import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -10,6 +11,8 @@ import java.time.LocalDateTime
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object ZipExportImportUtils { object ZipExportImportUtils {
@@ -27,11 +30,18 @@ object ZipExportImportUtils {
*/ */
fun createExportZip( fun createExportZip(
context: Context, context: Context,
exportData: com.atridad.openclimb.data.repository.ClimbDataExport, exportData: ClimbDataBackup,
referencedImagePaths: Set<String>, referencedImagePaths: Set<String>,
directory: File? = null directory: File? = null
): File { ): File {
val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb") val exportDir =
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"OpenClimb"
)
if (!exportDir.exists()) { if (!exportDir.exists()) {
exportDir.mkdirs() exportDir.mkdirs()
} }
@@ -53,7 +63,7 @@ object ZipExportImportUtils {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry) zipOut.putNextEntry(jsonEntry)
@@ -75,15 +85,24 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++ successfulImages++
} else { } else {
android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath") android.util.Log.w(
"ZipExportImportUtils",
"Image file not found or empty: $imagePath"
)
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
} }
} }
// Log export summary // Log export summary
android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included") android.util.Log.i(
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
} }
// Validate the created ZIP file // Validate the created ZIP file
@@ -92,7 +111,6 @@ object ZipExportImportUtils {
} }
return zipFile return zipFile
} catch (e: Exception) { } catch (e: Exception) {
// Clean up failed export // Clean up failed export
if (zipFile.exists()) { if (zipFile.exists()) {
@@ -112,7 +130,7 @@ object ZipExportImportUtils {
fun createExportZipToUri( fun createExportZipToUri(
context: Context, context: Context,
uri: android.net.Uri, uri: android.net.Uri,
exportData: com.atridad.openclimb.data.repository.ClimbDataExport, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) { ) {
try { try {
@@ -130,7 +148,7 @@ object ZipExportImportUtils {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME) val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
zipOut.putNextEntry(jsonEntry) zipOut.putNextEntry(jsonEntry)
@@ -153,21 +171,27 @@ object ZipExportImportUtils {
successfulImages++ successfulImages++
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
} }
} }
android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included") 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) { } catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}") throw IOException("Failed to create export ZIP to URI: ${e.message}")
} }
} }
private fun createMetadata( private fun createMetadata(
exportData: com.atridad.openclimb.data.repository.ClimbDataExport, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
): String { ): String {
return buildString { return buildString {
@@ -184,9 +208,7 @@ object ZipExportImportUtils {
} }
} }
/** /** Data class to hold extraction results */
* Data class to hold extraction results
*/
data class ImportResult( data class ImportResult(
val jsonContent: String, val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path val importedImagePaths: Map<String, String> // original filename -> new relative path
@@ -200,7 +222,6 @@ 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>() var foundRequiredFiles = mutableSetOf<String>()
@@ -212,28 +233,32 @@ object ZipExportImportUtils {
when { when {
entry.name == METADATA_FILENAME -> { entry.name == METADATA_FILENAME -> {
// Read metadata for validation // Read metadata for validation
metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata") foundRequiredFiles.add("metadata")
android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}") 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") 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 { 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
)
FileOutputStream(tempFile).use { output -> FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
zipIn.copyTo(output)
}
// Validate the extracted image // Validate the extracted image
if (tempFile.exists() && tempFile.length() > 0) { if (tempFile.exists() && tempFile.length() > 0) {
@@ -241,24 +266,37 @@ object ZipExportImportUtils {
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") android.util.Log.d(
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath"
)
} else { } else {
android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename") android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
} }
} else { } else {
android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename") 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) { } catch (e: Exception) {
android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}") android.util.Log.e(
"ZipExportImportUtils",
"Failed to process image $originalFilename: ${e.message}"
)
} }
} }
else -> { else -> {
android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}") android.util.Log.d(
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}"
)
} }
} }
@@ -276,30 +314,33 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty") throw IOException("Invalid ZIP file: data.json is empty")
} }
android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed") android.util.Log.i(
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed"
)
return ImportResult(jsonContent, importedImagePaths) return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) { } catch (e: Exception) {
throw IOException("Failed to extract import ZIP: ${e.message}") throw IOException("Failed to extract import ZIP: ${e.message}")
} }
} }
/** /**
* Updates image paths in a problem list after import * Updates image paths in a problem list after import This function maps the old image paths to
* This function maps the old image paths to the new ones after import * the new ones after import
*/ */
fun updateProblemImagePaths( fun updateProblemImagePaths(
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<BackupProblem>,
imagePathMapping: Map<String, String> imagePathMapping: Map<String, String>
): List<com.atridad.openclimb.data.model.Problem> { ): List<BackupProblem> {
return problems.map { problem -> return problems.map { problem ->
val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath -> val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path // Extract filename from the old path
val filename = oldPath.substringAfterLast("/") val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename] imagePathMapping[filename]
} }
problem.copy(imagePaths = updatedImagePaths) problem.withUpdatedImagePaths(updatedImagePaths)
} }
} }
} }

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.12.2" agp = "8.12.3"
kotlin = "2.2.10" kotlin = "2.2.20"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -9,12 +9,12 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0" androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.3" lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.10.1" activityCompose = "1.11.0"
composeBom = "2025.08.01" composeBom = "2025.09.01"
room = "2.7.2" room = "2.8.1"
navigation = "2.9.3" navigation = "2.9.5"
viewmodel = "2.9.3" viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0" kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" coil = "2.7.0"
@@ -39,6 +39,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room Database # Room Database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
@@ -59,7 +60,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing # Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" } mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
# 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" }
@@ -72,4 +73,3 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Binary file not shown.

View File

@@ -0,0 +1,383 @@
package com.atridad.openclimb.data.repository
import android.content.Context
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File
import java.time.LocalDateTime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
private val gymDao = database.gymDao()
private val problemDao = database.problemDao()
private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
// Gym operations
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym)
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
// Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
// Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
getGymById(recentSessions.first().gymId)
} else {
null
}
}
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
// ZIP Export with images - Single format for reliability
suspend fun exportAllDataToZip(directory: File? = null): File {
return try {
// Collect all data with proper error handling
val allGyms = gymDao.getAllGyms().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)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
exportedAt = LocalDateTime.now().toString(),
version = "2.0",
formatVersion = "2.0",
gyms =
allGyms.map {
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
},
problems =
allProblems.map {
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
it
)
},
sessions =
allSessions.map {
com.atridad.openclimb.data.format.BackupClimbSession
.fromClimbSession(it)
},
attempts =
allAttempts.map {
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
it
)
}
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
// Log any missing images for debugging
val missingImages = referencedImagePaths - validImagePaths
if (missingImages.isNotEmpty()) {
android.util.Log.w(
"ClimbRepository",
"Some referenced images are missing: $missingImages"
)
}
ZipExportImportUtils.createExportZip(
context = context,
exportData = backupData,
referencedImagePaths = validImagePaths,
directory = directory
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun exportAllDataToZipUri(uri: android.net.Uri) {
try {
// Collect all data
val allGyms = gymDao.getAllGyms().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)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
exportedAt = LocalDateTime.now().toString(),
version = "2.0",
formatVersion = "2.0",
gyms =
allGyms.map {
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
},
problems =
allProblems.map {
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
it
)
},
sessions =
allSessions.map {
com.atridad.openclimb.data.format.BackupClimbSession
.fromClimbSession(it)
},
attempts =
allAttempts.map {
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
it
)
}
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.openclimb.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (e: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
}
}
suspend fun importDataFromZip(file: File) {
try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
// Parse and validate the data structure
val importData =
try {
json.decodeFromString<ClimbDataBackup>(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 { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
} catch (e: Exception) {
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
}
}
// Import problems with updated image paths
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
// Import problems (depends on gyms)
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) {
throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}"
)
}
}
// Import sessions
importData.sessions.forEach { backupSession ->
try {
sessionDao.insertSession(backupSession.toClimbSession())
} catch (e: Exception) {
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
}
}
// Import attempts last (depends on problems and sessions)
importData.attempts.forEach { backupAttempt ->
try {
attemptDao.insertAttempt(backupAttempt.toAttempt())
} catch (e: Exception) {
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
}
}
} catch (e: Exception) {
throw Exception("Import failed: ${e.message}")
}
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// 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: ClimbDataBackup) {
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}")
}
}
}

View File

@@ -40,6 +40,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -107,6 +108,7 @@
D24C195F2E75002A0045894C = { D24C195F2E75002A0045894C = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2FE947F2E78E958008CDB25 /* Frameworks */, D2FE947F2E78E958008CDB25 /* Frameworks */,
@@ -389,8 +391,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -410,9 +414,10 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
@@ -429,8 +434,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -450,9 +457,10 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
@@ -469,8 +477,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -481,7 +490,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -498,8 +507,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -510,7 +520,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2FE948A2E78FEE0008CDB25"
BuildableName = "SessionStatusLiveExtension.appex"
BlueprintName = "SessionStatusLiveExtension"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -7,12 +7,25 @@
<key>OpenClimb.xcscheme_^#shared#^_</key> <key>OpenClimb.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
<key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key> <key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>D24C19672E75002A0045894C</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D2FE948A2E78FEE0008CDB25</key>
<dict>
<key>primary</key>
<true/>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@@ -4,6 +4,7 @@ struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager() @StateObject private var dataManager = ClimbingDataManager()
@State private var selectedTab = 0 @State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = []
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -43,10 +44,22 @@ struct ContentView: View {
.tag(4) .tag(4)
} }
.environmentObject(dataManager) .environmentObject(dataManager)
.onChange(of: scenePhase) { .onChange(of: scenePhase) { oldPhase, newPhase in
if scenePhase == .active { if newPhase == .active {
// Add slight delay to ensure app is fully loaded
Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
} }
} else if newPhase == .background {
dataManager.onAppEnterBackground()
}
}
.onAppear {
setupNotificationObservers()
}
.onDisappear {
removeNotificationObservers()
} }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let message = dataManager.successMessage { if let message = dataManager.successMessage {
@@ -62,6 +75,44 @@ struct ContentView: View {
} }
} }
} }
private func setupNotificationObservers() {
// Listen for when the app will enter foreground
let willEnterForegroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { _ in
print("📱 App will enter foreground - preparing Live Activity check")
Task {
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive()
}
}
// Listen for when the app becomes active
let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
print("📱 App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
}
}
notificationObservers = [willEnterForegroundObserver, didBecomeActiveObserver]
}
private func removeNotificationObservers() {
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
notificationObservers.removeAll()
}
} }
struct SuccessMessageView: View { struct SuccessMessageView: View {

View File

@@ -6,5 +6,7 @@
<true/> <true/>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,452 @@
//
// BackupFormat.swift
// OpenClimb
//
// Created by OpenClimb Team on 2024-12-19.
// Copyright © 2024 OpenClimb. All rights reserved.
//
import Foundation
// MARK: - Backup Format Specification v2.0
// Platform-neutral backup format for cross-platform compatibility
// This format ensures portability between iOS and Android while maintaining
// platform-specific implementations
/// Root structure for OpenClimb backup data
struct ClimbDataBackup: Codable {
let exportedAt: String
let version: String
let formatVersion: String
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
init(
exportedAt: String,
version: String = "2.0",
formatVersion: String = "2.0",
gyms: [BackupGym],
problems: [BackupProblem],
sessions: [BackupClimbSession],
attempts: [BackupAttempt]
) {
self.exportedAt = exportedAt
self.version = version
self.formatVersion = formatVersion
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
}
}
/// Platform-neutral gym representation for backup/restore
struct BackupGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS Gym model
init(from gym: Gym) {
self.id = gym.id.uuidString
self.name = gym.name
self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems
self.customDifficultyGrades = gym.customDifficultyGrades
self.notes = gym.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
name: String,
location: String?,
supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem],
customDifficultyGrades: [String] = [],
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS Gym model
func toGym() throws -> Gym {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
return Gym.fromImport(
id: uuid,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
/// Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let tags: [String]
let location: String?
let imagePaths: [String]?
let isActive: Bool
let dateSet: String? // ISO 8601 format
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS Problem model
init(from problem: Problem) {
self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString
self.name = problem.name
self.description = problem.description
self.climbType = problem.climbType
self.difficulty = problem.difficulty
self.tags = problem.tags
self.location = problem.location
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
self.isActive = problem.isActive
self.notes = problem.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.dateSet = problem.dateSet.map { formatter.string(from: $0) }
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
name: String?,
description: String?,
climbType: ClimbType,
difficulty: DifficultyGrade,
tags: [String] = [],
location: String?,
imagePaths: [String]?,
isActive: Bool,
dateSet: String?,
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS Problem model
func toProblem() throws -> Problem {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let dateSetDate = dateSet.flatMap { formatter.date(from: $0) }
return Problem.fromImport(
id: uuid,
gymId: gymUuid,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
tags: tags,
location: location,
imagePaths: imagePaths ?? [],
isActive: isActive,
dateSet: dateSetDate,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
/// Create a copy with updated image paths for import processing
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
return BackupProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
tags: self.tags,
location: self.location,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
isActive: self.isActive,
dateSet: self.dateSet,
notes: self.notes,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
/// Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable {
let id: String
let gymId: String
let date: String // ISO 8601 format
let startTime: String? // ISO 8601 format
let endTime: String? // ISO 8601 format
let duration: Int64? // Duration in seconds
let status: SessionStatus
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
self.status = session.status
self.notes = session.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.date = formatter.string(from: session.date)
self.startTime = session.startTime.map { formatter.string(from: $0) }
self.endTime = session.endTime.map { formatter.string(from: $0) }
self.duration = session.duration.map { Int64($0) }
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
date: String,
startTime: String?,
endTime: String?,
duration: Int64?,
status: SessionStatus,
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS ClimbSession model
func toClimbSession() throws -> ClimbSession {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let dateValue = formatter.date(from: date),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let startTimeValue = startTime.flatMap { formatter.date(from: $0) }
let endTimeValue = endTime.flatMap { formatter.date(from: $0) }
let durationValue = duration.map { Int($0) }
return ClimbSession.fromImport(
id: uuid,
gymId: gymUuid,
date: dateValue,
startTime: startTimeValue,
endTime: endTimeValue,
duration: durationValue,
status: status,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
/// Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds
let timestamp: String // ISO 8601 format
let createdAt: String // ISO 8601 format
/// Initialize from native iOS Attempt model
init(from attempt: Attempt) {
self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString
self.problemId = attempt.problemId.uuidString
self.result = attempt.result
self.highestHold = attempt.highestHold
self.notes = attempt.notes
self.duration = attempt.duration.map { Int64($0) }
self.restTime = attempt.restTime.map { Int64($0) }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
sessionId: String,
problemId: String,
result: AttemptResult,
highestHold: String?,
notes: String?,
duration: Int64?,
restTime: Int64?,
timestamp: String,
createdAt: String
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
/// Convert to native iOS Attempt model
func toAttempt() throws -> Attempt {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let sessionUuid = UUID(uuidString: sessionId),
let problemUuid = UUID(uuidString: problemId),
let timestampDate = formatter.date(from: timestamp),
let createdDate = formatter.date(from: createdAt)
else {
throw BackupError.invalidDateFormat
}
let durationValue = duration.map { Int($0) }
let restTimeValue = restTime.map { Int($0) }
return Attempt.fromImport(
id: uuid,
sessionId: sessionUuid,
problemId: problemUuid,
result: result,
highestHold: highestHold,
notes: notes,
duration: durationValue,
restTime: restTimeValue,
timestamp: timestampDate,
createdAt: createdDate
)
}
}
// MARK: - Backup Format Errors
enum BackupError: LocalizedError {
case invalidDateFormat
case invalidUUID
case missingRequiredField(String)
case unsupportedFormatVersion(String)
var errorDescription: String? {
switch self {
case .invalidDateFormat:
return "Invalid date format in backup data"
case .invalidUUID:
return "Invalid UUID format in backup data"
case .missingRequiredField(let field):
return "Missing required field: \(field)"
case .unsupportedFormatVersion(let version):
return "Unsupported backup format version: \(version)"
}
}
}
// MARK: - Extensions
// MARK: - Helper Extensions for Optional Mapping
extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? {
return self.flatMap { .some(transform($0)) }
}
}

View File

@@ -260,7 +260,7 @@ struct Problem: Identifiable, Codable, Hashable {
let description: String? let description: String?
let climbType: ClimbType let climbType: ClimbType
let difficulty: DifficultyGrade let difficulty: DifficultyGrade
let setter: String?
let tags: [String] let tags: [String]
let location: String? let location: String?
let imagePaths: [String] let imagePaths: [String]
@@ -272,7 +272,7 @@ struct Problem: Identifiable, Codable, Hashable {
init( init(
gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType, gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [], difficulty: DifficultyGrade, tags: [String] = [],
location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil, location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil,
notes: String? = nil notes: String? = nil
) { ) {
@@ -282,7 +282,7 @@ struct Problem: Identifiable, Codable, Hashable {
self.description = description self.description = description
self.climbType = climbType self.climbType = climbType
self.difficulty = difficulty self.difficulty = difficulty
self.setter = setter
self.tags = tags self.tags = tags
self.location = location self.location = location
self.imagePaths = imagePaths self.imagePaths = imagePaths
@@ -296,7 +296,7 @@ struct Problem: Identifiable, Codable, Hashable {
func updated( func updated(
name: String? = nil, description: String? = nil, climbType: ClimbType? = nil, name: String? = nil, description: String? = nil, climbType: ClimbType? = nil,
difficulty: DifficultyGrade? = nil, setter: String? = nil, tags: [String]? = nil, difficulty: DifficultyGrade? = nil, tags: [String]? = nil,
location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil, location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil,
dateSet: Date? = nil, notes: String? = nil dateSet: Date? = nil, notes: String? = nil
) -> Problem { ) -> Problem {
@@ -307,7 +307,7 @@ struct Problem: Identifiable, Codable, Hashable {
description: description ?? self.description, description: description ?? self.description,
climbType: climbType ?? self.climbType, climbType: climbType ?? self.climbType,
difficulty: difficulty ?? self.difficulty, difficulty: difficulty ?? self.difficulty,
setter: setter ?? self.setter,
tags: tags ?? self.tags, tags: tags ?? self.tags,
location: location ?? self.location, location: location ?? self.location,
imagePaths: imagePaths ?? self.imagePaths, imagePaths: imagePaths ?? self.imagePaths,
@@ -321,7 +321,7 @@ struct Problem: Identifiable, Codable, Hashable {
private init( private init(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, difficulty: DifficultyGrade, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date updatedAt: Date
) { ) {
@@ -331,7 +331,7 @@ struct Problem: Identifiable, Codable, Hashable {
self.description = description self.description = description
self.climbType = climbType self.climbType = climbType
self.difficulty = difficulty self.difficulty = difficulty
self.setter = setter
self.tags = tags self.tags = tags
self.location = location self.location = location
self.imagePaths = imagePaths self.imagePaths = imagePaths
@@ -344,7 +344,7 @@ struct Problem: Identifiable, Codable, Hashable {
static func fromImport( static func fromImport(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, difficulty: DifficultyGrade, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date updatedAt: Date
) -> Problem { ) -> Problem {
@@ -355,7 +355,7 @@ struct Problem: Identifiable, Codable, Hashable {
description: description, description: description,
climbType: climbType, climbType: climbType,
difficulty: difficulty, difficulty: difficulty,
setter: setter,
tags: tags, tags: tags,
location: location, location: location,
imagePaths: imagePaths, imagePaths: imagePaths,

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.OpenClimb</string>
</array>
</dict>
</plist>

View File

@@ -1,4 +1,3 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -522,7 +521,7 @@ class ImageManager {
} }
} }
private func getFullPath(from relativePath: String) -> String { func getFullPath(from relativePath: String) -> String {
// If it's already a full path, check if it's legacy and needs migration // If it's already a full path, check if it's legacy and needs migration
if relativePath.hasPrefix("/") { if relativePath.hasPrefix("/") {
// If it's pointing to legacy Documents directory, redirect to new location // If it's pointing to legacy Documents directory, redirect to new location

View File

@@ -1,4 +1,3 @@
import Compression import Compression
import Foundation import Foundation
import zlib import zlib
@@ -10,7 +9,7 @@ struct ZipUtils {
private static let METADATA_FILENAME = "metadata.txt" private static let METADATA_FILENAME = "metadata.txt"
static func createExportZip( static func createExportZip(
exportData: ClimbDataExport, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) throws -> Data { ) throws -> Data {
@@ -196,7 +195,7 @@ struct ZipUtils {
} }
private static func createMetadata( private static func createMetadata(
exportData: ClimbDataExport, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>
) -> String { ) -> String {
return """ return """

View File

@@ -7,6 +7,10 @@ import UniformTypeIdentifiers
import WidgetKit import WidgetKit
#endif #endif
#if canImport(ActivityKit)
import ActivityKit
#endif
@MainActor @MainActor
class ClimbingDataManager: ObservableObject { class ClimbingDataManager: ObservableObject {
@@ -23,6 +27,7 @@ class ClimbingDataManager: ObservableObject {
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol?
private enum Keys { private enum Keys {
static let gyms = "openclimb_gyms" static let gyms = "openclimb_gyms"
@@ -32,10 +37,32 @@ class ClimbingDataManager: ObservableObject {
static let activeSession = "openclimb_active_session" static let activeSession = "openclimb_active_session"
} }
// Widget data models
private struct WidgetAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let timestamp: Date
let result: String
}
private struct WidgetSession: Codable {
let id: String
let gymId: String
let date: Date
let status: String
}
private struct WidgetGym: Codable {
let id: String
let name: String
}
init() { init() {
_ = ImageManager.shared _ = ImageManager.shared
loadAllData() loadAllData()
migrateImagePaths() migrateImagePaths()
setupLiveActivityNotifications()
Task { Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
@@ -46,6 +73,12 @@ class ClimbingDataManager: ObservableObject {
} }
} }
deinit {
if let observer = liveActivityObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func loadAllData() { private func loadAllData() {
loadGyms() loadGyms()
loadProblems() loadProblems()
@@ -97,8 +130,13 @@ class ClimbingDataManager: ObservableObject {
private func saveGyms() { private func saveGyms() {
if let data = try? encoder.encode(gyms) { if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms) userDefaults.set(data, forKey: Keys.gyms)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.gyms) let widgetGyms = gyms.map { gym in
WidgetGym(id: gym.id.uuidString, name: gym.name)
}
if let widgetData = try? encoder.encode(widgetGyms) {
sharedUserDefaults?.set(widgetData, forKey: Keys.gyms)
}
} }
} }
@@ -113,16 +151,37 @@ class ClimbingDataManager: ObservableObject {
private func saveSessions() { private func saveSessions() {
if let data = try? encoder.encode(sessions) { if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions) userDefaults.set(data, forKey: Keys.sessions)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.sessions) let widgetSessions = sessions.map { session in
WidgetSession(
id: session.id.uuidString,
gymId: session.gymId.uuidString,
date: session.date,
status: session.status.rawValue
)
}
if let widgetData = try? encoder.encode(widgetSessions) {
sharedUserDefaults?.set(widgetData, forKey: Keys.sessions)
}
} }
} }
private func saveAttempts() { private func saveAttempts() {
if let data = try? encoder.encode(attempts) { if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts) userDefaults.set(data, forKey: Keys.attempts)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.attempts) let widgetAttempts = attempts.map { attempt in
WidgetAttempt(
id: attempt.id.uuidString,
sessionId: attempt.sessionId.uuidString,
problemId: attempt.problemId.uuidString,
timestamp: attempt.timestamp,
result: attempt.result.rawValue
)
}
if let widgetData = try? encoder.encode(widgetAttempts) {
sharedUserDefaults?.set(widgetData, forKey: Keys.attempts)
}
// Update widget timeline // Update widget timeline
updateWidgetTimeline() updateWidgetTimeline()
} }
@@ -414,23 +473,33 @@ class ClimbingDataManager: ObservableObject {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let exportData = ClimbDataExport( let exportData = ClimbDataBackup(
exportedAt: dateFormatter.string(from: Date()), exportedAt: dateFormatter.string(from: Date()),
gyms: gyms.map { AndroidGym(from: $0) }, version: "2.0",
problems: problems.map { AndroidProblem(from: $0) }, formatVersion: "2.0",
sessions: sessions.map { AndroidClimbSession(from: $0) }, gyms: gyms.map { BackupGym(from: $0) },
attempts: attempts.map { AndroidAttempt(from: $0) } problems: problems.map { BackupProblem(from: $0) },
sessions: sessions.map { BackupClimbSession(from: $0) },
attempts: attempts.map { BackupAttempt(from: $0) }
) )
// Collect referenced image paths // Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths() let referencedImagePaths = collectReferencedImagePaths()
print("🎯 Starting export with \(referencedImagePaths.count) images")
return try ZipUtils.createExportZip( let zipData = try ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("✅ Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay()
return zipData
} catch { } catch {
setError("Export failed: \(error.localizedDescription)") let errorMessage = "Export failed: \(error.localizedDescription)"
print("\(errorMessage)")
setError(errorMessage)
return nil return nil
} }
} }
@@ -461,7 +530,7 @@ class ClimbingDataManager: ObservableObject {
print("Raw JSON content preview:") print("Raw JSON content preview:")
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData) let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
print("Successfully decoded import data:") print("Successfully decoded import data:")
print("- Gyms: \(importData.gyms.count)") print("- Gyms: \(importData.gyms.count)")
@@ -478,10 +547,10 @@ class ClimbingDataManager: ObservableObject {
imagePathMapping: importResult.imagePathMapping imagePathMapping: importResult.imagePathMapping
) )
self.gyms = importData.gyms.map { $0.toGym() } self.gyms = try importData.gyms.map { try $0.toGym() }
self.problems = updatedProblems.map { $0.toProblem() } self.problems = try updatedProblems.map { try $0.toProblem() }
self.sessions = importData.sessions.map { $0.toClimbSession() } self.sessions = try importData.sessions.map { try $0.toClimbSession() }
self.attempts = importData.attempts.map { $0.toAttempt() } self.attempts = try importData.attempts.map { try $0.toAttempt() }
saveGyms() saveGyms()
saveProblems() saveProblems()
@@ -516,317 +585,43 @@ class ClimbingDataManager: ObservableObject {
} }
} }
struct ClimbDataExport: Codable {
let exportedAt: String
let gyms: [AndroidGym]
let problems: [AndroidProblem]
let sessions: [AndroidClimbSession]
let attempts: [AndroidAttempt]
init(
exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem],
sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
) {
self.exportedAt = exportedAt
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
}
}
struct AndroidGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let notes: String?
let createdAt: String
let updatedAt: String
init(from gym: Gym) {
self.id = gym.id.uuidString
self.name = gym.name
self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems
self.notes = gym.notes
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
init(
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toGym() -> Gym {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let gymId = UUID(uuidString: id) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Gym.fromImport(
id: gymId,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: [],
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let imagePaths: [String]?
let createdAt: String
let updatedAt: String
init(from problem: Problem) {
self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString
self.name = problem.name
self.description = problem.description
self.climbType = problem.climbType
self.difficulty = problem.difficulty
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
init(
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.imagePaths = imagePaths
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toProblem() -> Problem {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let problemId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Problem.fromImport(
id: problemId,
gymId: preservedGymId,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
setter: nil,
tags: [],
location: nil,
imagePaths: imagePaths ?? [],
isActive: true,
dateSet: nil,
notes: nil,
createdAt: createdDate,
updatedAt: updatedDate
)
}
func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem {
return AndroidProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
struct AndroidClimbSession: Codable {
let id: String
let gymId: String
let date: String
let startTime: String?
let endTime: String?
let duration: Int?
let status: SessionStatus
let createdAt: String
let updatedAt: String
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.date = formatter.string(from: session.date)
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
self.duration = session.duration
self.status = session.status
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
init(
id: String, gymId: String, date: String, startTime: String?, endTime: String?,
duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toClimbSession() -> ClimbSession {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
// Preserve original IDs and dates
let sessionId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let sessionDate = formatter.date(from: date) ?? Date()
let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil
let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return ClimbSession.fromImport(
id: sessionId,
gymId: preservedGymId,
date: sessionDate,
startTime: sessionStartTime,
endTime: sessionEndTime,
duration: duration,
status: status,
notes: nil,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int?
let restTime: Int?
let timestamp: String
let createdAt: String
init(from attempt: Attempt) {
self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString
self.problemId = attempt.problemId.uuidString
self.result = attempt.result
self.highestHold = attempt.highestHold
self.notes = attempt.notes
self.duration = attempt.duration
self.restTime = attempt.restTime
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
}
init(
id: String, sessionId: String, problemId: String, result: AttemptResult,
highestHold: String?, notes: String?, duration: Int?, restTime: Int?,
timestamp: String, createdAt: String
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
func toAttempt() -> Attempt {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let attemptId = UUID(uuidString: id) ?? UUID()
let preservedSessionId = UUID(uuidString: sessionId) ?? UUID()
let preservedProblemId = UUID(uuidString: problemId) ?? UUID()
let attemptTimestamp = formatter.date(from: timestamp) ?? Date()
let createdDate = formatter.date(from: createdAt) ?? Date()
return Attempt.fromImport(
id: attemptId,
sessionId: preservedSessionId,
problemId: preservedProblemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration,
restTime: restTime,
timestamp: attemptTimestamp,
createdAt: createdDate
)
}
}
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>() var imagePaths = Set<String>()
print("🖼️ Starting image path collection...")
print("📊 Total problems: \(problems.count)")
for problem in problems { for problem in problems {
imagePaths.formUnion(problem.imagePaths) if !problem.imagePaths.isEmpty {
print(
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
print(" - Full path: \(fullPath)")
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" ✅ File exists")
imagePaths.insert(fullPath)
} else {
print(" ❌ File does NOT exist")
// Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath)
} }
}
}
}
print("🖼️ Collected \(imagePaths.count) total image paths for export")
return imagePaths return imagePaths
} }
private func updateProblemImagePaths( private func updateProblemImagePaths(
problems: [AndroidProblem], problems: [BackupProblem],
imagePathMapping: [String: String] imagePathMapping: [String: String]
) -> [AndroidProblem] { ) -> [BackupProblem] {
return problems.map { problem in return problems.map { problem in
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
@@ -999,29 +794,123 @@ extension ClimbingDataManager {
} }
private func checkAndRestartLiveActivity() async { private func checkAndRestartLiveActivity() async {
guard let activeSession = activeSession else { return } guard let activeSession = activeSession else {
// No active session, make sure all Live Activities are cleaned up
await LiveActivityManager.shared.endLiveActivity()
return
}
// Only restart if session is actually active
guard activeSession.status == .active else {
print(
"⚠️ Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
)
await LiveActivityManager.shared.endLiveActivity()
return
}
if let gym = gym(withId: activeSession.gymId) { if let gym = gym(withId: activeSession.gymId) {
print("🔍 Checking Live Activity for active session at \(gym.name)")
// First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities()
// Then attempt to restart if needed
await LiveActivityManager.shared.restartLiveActivityIfNeeded( await LiveActivityManager.shared.restartLiveActivityIfNeeded(
activeSession: activeSession, activeSession: activeSession,
gymName: gym.name gymName: gym.name
) )
// Update with current session data
await updateLiveActivityData()
} }
} }
/// Call this when app becomes active to check for Live Activity restart /// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() { func onAppBecomeActive() {
print("📱 App became active - checking Live Activity status")
Task { Task {
await checkAndRestartLiveActivity() await checkAndRestartLiveActivity()
} }
} }
/// Call this when app enters background to update Live Activity
func onAppEnterBackground() {
print("📱 App entering background - updating Live Activity if needed")
Task {
await updateLiveActivityData()
}
}
/// Setup notifications for Live Activity events
private func setupLiveActivityNotifications() {
liveActivityObserver = NotificationCenter.default.addObserver(
forName: .liveActivityDismissed,
object: nil,
queue: .main
) { [weak self] _ in
print("🔔 Received Live Activity dismissed notification - attempting restart")
Task { @MainActor in
await self?.handleLiveActivityDismissed()
}
}
}
/// Handle Live Activity being dismissed by user
private func handleLiveActivityDismissed() async {
guard let activeSession = activeSession,
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
return
}
print("🔄 Attempting to restart dismissed Live Activity for \(gym.name)")
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
await LiveActivityManager.shared.startLiveActivity(
for: activeSession,
gymName: gym.name
)
// Update with current data
await updateLiveActivityData()
}
/// Update Live Activity with current session statistics
private func updateLiveActivityData() async {
guard let activeSession = activeSession,
activeSession.status == .active
else { return }
let elapsed = Date().timeIntervalSince(activeSession.startTime ?? activeSession.date)
let sessionAttempts = attempts.filter { $0.sessionId == activeSession.id }
let totalAttempts = sessionAttempts.count
let completedProblems = Set(
sessionAttempts.filter { $0.result.isSuccessful }.map { $0.problemId }
).count
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
/// Update Live Activity with current session data /// Update Live Activity with current session data
private func updateLiveActivityForActiveSession() { private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession, guard let activeSession = activeSession,
activeSession.status == .active, activeSession.status == .active,
let _ = gym(withId: activeSession.gymId) let gym = gym(withId: activeSession.gymId)
else { else {
print("⚠️ Live Activity update skipped - no active session or gym")
if let session = activeSession {
print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)")
print(" Gym ID: \(session.gymId)")
}
return return
} }
@@ -1040,6 +929,16 @@ extension ClimbingDataManager {
elapsedInterval = 0 elapsedInterval = 0
} }
print("🔄 Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)")
print(" Completed problems: \(completedProblems)")
print(" Elapsed time: \(elapsedInterval) seconds")
print(
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })"
)
Task { Task {
await LiveActivityManager.shared.updateLiveActivity( await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsedInterval, elapsed: elapsedInterval,
@@ -1061,7 +960,15 @@ extension ClimbingDataManager {
#endif #endif
} }
private func validateImportData(_ importData: ClimbDataExport) throws { /// Debug function to manually trigger widget data update
func debugUpdateWidgetData() {
// Force save all data to widget
saveGyms()
saveSessions()
saveAttempts()
}
private func validateImportData(_ importData: ClimbDataBackup) throws {
if importData.gyms.isEmpty { if importData.gyms.isEmpty {
throw NSError( throw NSError(
domain: "ImportError", code: 1, domain: "ImportError", code: 1,
@@ -1089,7 +996,6 @@ extension ClimbingDataManager {
description: "Technical overhang with small holds", description: "Technical overhang with small holds",
climbType: .boulder, climbType: .boulder,
difficulty: DifficultyGrade(system: .vScale, grade: "V4"), difficulty: DifficultyGrade(system: .vScale, grade: "V4"),
setter: "John Doe",
tags: ["technical", "overhang"], tags: ["technical", "overhang"],
location: "Cave area" location: "Cave area"
) )

View File

@@ -1,12 +1,22 @@
import ActivityKit import ActivityKit
import Foundation import Foundation
extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
}
@MainActor @MainActor
final class LiveActivityManager { final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
private var currentActivity: Activity<SessionActivityAttributes>? private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date()
deinit {
healthCheckTimer?.invalidate()
}
/// Check if there's an active session and restart Live Activity if needed /// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
@@ -18,13 +28,31 @@ final class LiveActivityManager {
return return
} }
// Check if we already have a running Live Activity // Check if we have a tracked Live Activity that's still actually running
if currentActivity != nil { if let currentActivity = currentActivity {
print(" Live Activity already running") let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
print(" Live Activity still running: \(currentActivity.id)")
return
} else {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
)
self.currentActivity = nil
}
}
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
print(" Found existing Live Activity: \(existingActivity.id), using it")
self.currentActivity = existingActivity
return return
} }
print("🔄 Restarting Live Activity for existing session") print("🔄 No Live Activity found, restarting for existing session")
await startLiveActivity(for: activeSession, gymName: gymName) await startLiveActivity(for: activeSession, gymName: gymName)
} }
@@ -34,10 +62,17 @@ final class LiveActivityManager {
await endLiveActivity() await endLiveActivity()
// Start health checks once we have an active session
startHealthChecks()
// Calculate elapsed time if session already started
let startTime = session.startTime ?? session.date
let elapsed = Date().timeIntervalSince(startTime)
let attributes = SessionActivityAttributes( let attributes = SessionActivityAttributes(
gymName: gymName, startTime: session.startTime ?? session.date) gymName: gymName, startTime: startTime)
let initialContentState = SessionActivityAttributes.ContentState( let initialContentState = SessionActivityAttributes.ContentState(
elapsed: 0, elapsed: elapsed,
totalAttempts: 0, totalAttempts: 0,
completedProblems: 0 completedProblems: 0
) )
@@ -59,6 +94,8 @@ final class LiveActivityManager {
print("Authorization error - check Live Activity permissions in Settings") print("Authorization error - check Live Activity permissions in Settings")
} else if error.localizedDescription.contains("content") { } else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure") print("Content error - check ActivityAttributes structure")
} else if error.localizedDescription.contains("frequencyLimited") {
print("Frequency limited - too many Live Activities started recently")
} }
} }
} }
@@ -66,11 +103,23 @@ final class LiveActivityManager {
/// Call this to update the Live Activity with new session progress /// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{ {
guard let currentActivity else { guard let currentActivity = currentActivity else {
print("⚠️ No current activity to update") print("⚠️ No current activity to update")
return return
} }
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
)
self.currentActivity = nil
return
}
print( print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" "🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
) )
@@ -87,6 +136,9 @@ final class LiveActivityManager {
/// Call this when a ClimbSession ends to end the Live Activity /// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async { func endLiveActivity() async {
// Stop health checks first
stopHealthChecks()
// First end the tracked activity if it exists // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)") print("🔴 Ending tracked Live Activity: \(currentActivity.id)")
@@ -115,18 +167,92 @@ final class LiveActivityManager {
func checkLiveActivityAvailability() -> String { func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo() let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled let status = authorizationInfo.areActivitiesEnabled
let allActivities = Activity<SessionActivityAttributes>.activities
let message = """ let message = """
Live Activity Status: Live Activity Status:
• Enabled: \(status) • Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown") • Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
Current Activity: \(currentActivity?.id.description ?? "None") Tracked Activity: \(currentActivity?.id.description ?? "None")
• All Active Activities: \(allActivities.count)
""" """
print(message) print(message)
return message return message
} }
/// Force check and cleanup dismissed Live Activities
func cleanupDismissedActivities() async {
let activities = Activity<SessionActivityAttributes>.activities
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("🧹 Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil
}
}
}
/// Start periodic health checks for Live Activity
func startHealthChecks() {
stopHealthChecks() // Stop any existing timer
print("🩺 Starting Live Activity health checks")
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor in
await self?.performHealthCheck()
}
}
}
/// Stop periodic health checks
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
print("🛑 Stopped Live Activity health checks")
}
/// Perform a health check on the current Live Activity
private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return }
let now = Date()
let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck)
// Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return }
print("🩺 Performing Live Activity health check")
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("💔 Health check failed - Live Activity was dismissed")
self.currentActivity = nil
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
} else {
print("✅ Live Activity health check passed")
}
}
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None"
let actualCount = activities.count
return "Status: \(trackedStatus) | Active Count: \(actualCount)"
}
/// Start periodic updates for Live Activity /// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{ {

View File

@@ -1,3 +1,4 @@
import PhotosUI
import SwiftUI import SwiftUI
struct AddAttemptView: View { struct AddAttemptView: View {
@@ -19,6 +20,8 @@ struct AddAttemptView: View {
@State private var newProblemGrade = "" @State private var newProblemGrade = ""
@State private var selectedClimbType: ClimbType = .boulder @State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale @State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
private var activeProblems: [Problem] { private var activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id) dataManager.activeProblems(forGym: gym.id)
@@ -126,6 +129,8 @@ struct AddAttemptView: View {
Button("Back") { Button("Back") {
showingCreateProblem = false showingCreateProblem = false
selectedPhotos = []
imageData = []
} }
.foregroundColor(.blue) .foregroundColor(.blue)
} }
@@ -209,6 +214,74 @@ struct AddAttemptView: View {
} }
} }
} }
Section("Photos (Optional)") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(.blue)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.onChange(of: selectedPhotos) { _, _ in
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: {
imageData.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
}
.offset(x: 8, y: -8)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
}
}
}
} }
@ViewBuilder @ViewBuilder
@@ -310,11 +383,20 @@ struct AddAttemptView: View {
let difficulty = DifficultyGrade( let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade) system: selectedDifficultySystem, grade: newProblemGrade)
// Save images and get paths
var imagePaths: [String] = []
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
}
}
let newProblem = Problem( let newProblem = Problem(
gymId: gym.id, gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName, name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty difficulty: difficulty,
imagePaths: imagePaths
) )
dataManager.addProblem(newProblem) dataManager.addProblem(newProblem)
@@ -347,8 +429,26 @@ struct AddAttemptView: View {
dataManager.addAttempt(attempt) dataManager.addAttempt(attempt)
} }
// Clear photo states after saving
selectedPhotos = []
imageData = []
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
struct ProblemSelectionRow: View { struct ProblemSelectionRow: View {
@@ -535,12 +635,6 @@ struct ProblemExpandedView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
if let setter = problem.setter, !setter.isEmpty {
Label(setter, systemImage: "person")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let description = problem.description, !description.isEmpty { if let description = problem.description, !description.isEmpty {
Text(description) Text(description)
.font(.body) .font(.body)
@@ -592,9 +686,43 @@ struct EditAttemptView: View {
@State private var notes: String @State private var notes: String
@State private var duration: Int @State private var duration: Int
@State private var restTime: Int @State private var restTime: Int
@State private var showingCreateProblem = false
// New problem creation state
@State private var newProblemName = ""
@State private var newProblemGrade = ""
@State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
private var availableProblems: [Problem] { private var availableProblems: [Problem] {
dataManager.problems.filter { $0.isActive } guard let session = dataManager.session(withId: attempt.sessionId) else {
return []
}
return dataManager.problems.filter { $0.isActive && $0.gymId == session.gymId }
}
private var gym: Gym? {
guard let session = dataManager.session(withId: attempt.sessionId) else {
return nil
}
return dataManager.gym(withId: session.gymId)
}
private var availableClimbTypes: [ClimbType] {
gym?.supportedClimbTypes ?? []
}
private var availableDifficultySystems: [DifficultySystem] {
guard let gym = gym else { return [] }
return DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
gym.difficultySystems.contains(system)
}
}
private var availableGrades: [String] {
selectedDifficultySystem.availableGrades
} }
init(attempt: Attempt) { init(attempt: Attempt) {
@@ -609,10 +737,57 @@ struct EditAttemptView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { Form {
if !showingCreateProblem {
ProblemSelectionSection()
} else {
CreateProblemSection()
}
AttemptDetailsSection()
}
.navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Update") {
updateAttempt()
}
.disabled(!canSave)
}
}
}
.onAppear {
selectedProblem = dataManager.problem(withId: attempt.problemId)
setupInitialValues()
}
.onChange(of: selectedClimbType) {
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded()
}
}
@ViewBuilder
private func ProblemSelectionSection() -> some View {
Section("Select Problem") { Section("Select Problem") {
if availableProblems.isEmpty { if availableProblems.isEmpty {
Text("No problems available") VStack(alignment: .leading, spacing: 12) {
Text("No active problems in this gym")
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("Create New Problem") {
showingCreateProblem = true
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 8)
} else { } else {
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
@@ -628,10 +803,184 @@ struct EditAttemptView: View {
} }
} }
.padding(.vertical, 8) .padding(.vertical, 8)
Button("Create New Problem") {
showingCreateProblem = true
}
.foregroundColor(.blue)
}
} }
} }
Section("Result") { @ViewBuilder
private func CreateProblemSection() -> some View {
Section {
HStack {
Text("Create New Problem")
.font(.headline)
Spacer()
Button("Back") {
showingCreateProblem = false
selectedPhotos = []
imageData = []
}
.foregroundColor(.blue)
}
}
Section("Problem Details") {
TextField("Problem Name", text: $newProblemName)
}
Section("Climb Type") {
ForEach(availableClimbTypes, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
Section("Difficulty") {
VStack(alignment: .leading, spacing: 12) {
Text("Difficulty System")
.font(.subheadline)
.fontWeight(.medium)
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedDifficultySystem = system
}
}
}
if selectedDifficultySystem == .custom {
TextField("Grade (Required - numbers only)", text: $newProblemGrade)
.keyboardType(.numberPad)
.onChange(of: newProblemGrade) {
// Filter out non-numeric characters
newProblemGrade = newProblemGrade.filter { $0.isNumber }
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Grade (Required)")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(availableGrades, id: \.self) { grade in
Button(grade) {
newProblemGrade = grade
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(newProblemGrade == grade ? .blue : .gray)
}
}
.padding(.horizontal, 1)
}
}
}
}
Section("Photos (Optional)") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(.blue)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.onChange(of: selectedPhotos) { _, _ in
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: {
imageData.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
}
.offset(x: 8, y: -8)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
}
}
}
}
@ViewBuilder
private func AttemptDetailsSection() -> some View {
Section("Attempt Result") {
ForEach(AttemptResult.allCases, id: \.self) { result in ForEach(AttemptResult.allCases, id: \.self) { result in
HStack { HStack {
Text(result.displayName) Text(result.displayName)
@@ -651,7 +1000,7 @@ struct EditAttemptView: View {
} }
} }
Section("Details") { Section("Additional Details") {
TextField("Highest Hold (Optional)", text: $highestHold) TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -686,29 +1035,81 @@ struct EditAttemptView: View {
} }
} }
} }
.navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline) private var canSave: Bool {
.toolbar { if showingCreateProblem {
ToolbarItem(placement: .navigationBarLeading) { return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
Button("Cancel") { } else {
dismiss() return selectedProblem != nil
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { private func setupInitialValues() {
Button("Update") { guard let gym = gym else { return }
updateAttempt()
// Auto-select climb type if there's only one available
if gym.supportedClimbTypes.count == 1 {
selectedClimbType = gym.supportedClimbTypes.first!
} }
.disabled(selectedProblem == nil)
updateDifficultySystem()
}
private func updateDifficultySystem() {
let available = availableDifficultySystems
if !available.contains(selectedDifficultySystem) {
selectedDifficultySystem = available.first ?? .custom
}
if available.count == 1 {
selectedDifficultySystem = available.first!
} }
} }
}
.onAppear { private func resetGradeIfNeeded() {
selectedProblem = dataManager.problem(withId: attempt.problemId) let availableGrades = selectedDifficultySystem.availableGrades
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
newProblemGrade = ""
} }
} }
private func updateAttempt() { private func updateAttempt() {
if showingCreateProblem {
guard let gym = gym else { return }
let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade)
// Save images and get paths
var imagePaths: [String] = []
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
}
}
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty,
imagePaths: imagePaths
)
dataManager.addProblem(newProblem)
let updatedAttempt = attempt.updated(
problemId: newProblem.id,
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration > 0 ? duration : nil,
restTime: restTime > 0 ? restTime : nil
)
dataManager.updateAttempt(updatedAttempt)
} else {
guard selectedProblem != nil else { return } guard selectedProblem != nil else { return }
let updatedAttempt = attempt.updated( let updatedAttempt = attempt.updated(
@@ -721,8 +1122,28 @@ struct EditAttemptView: View {
) )
dataManager.updateAttempt(updatedAttempt) dataManager.updateAttempt(updatedAttempt)
}
// Clear photo states after saving
selectedPhotos = []
imageData = []
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
#Preview { #Preview {
@@ -769,6 +1190,7 @@ struct ProblemSelectionImageView: View {
ProgressView() ProgressView()
.scaleEffect(0.8) .scaleEffect(0.8)
} }
} }
} }
.onAppear { .onAppear {

View File

@@ -1,4 +1,3 @@
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
@@ -14,7 +13,6 @@ struct AddEditProblemView: View {
@State private var selectedClimbType: ClimbType = .boulder @State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale @State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var difficultyGrade = "" @State private var difficultyGrade = ""
@State private var setter = ""
@State private var location = "" @State private var location = ""
@State private var tags = "" @State private var tags = ""
@State private var notes = "" @State private var notes = ""
@@ -61,11 +59,11 @@ struct AddEditProblemView: View {
Form { Form {
GymSelectionSection() GymSelectionSection()
BasicInfoSection() BasicInfoSection()
PhotosSection()
ClimbTypeSection() ClimbTypeSection()
DifficultySection() DifficultySection()
LocationAndSetterSection() LocationSection()
TagsSection() TagsSection()
PhotosSection()
AdditionalInfoSection() AdditionalInfoSection()
} }
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem") .navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
@@ -159,7 +157,6 @@ struct AddEditProblemView: View {
) )
} }
TextField("Route Setter (Optional)", text: $setter)
} }
} }
@@ -282,7 +279,7 @@ struct AddEditProblemView: View {
} }
@ViewBuilder @ViewBuilder
private func LocationAndSetterSection() -> some View { private func LocationSection() -> some View {
Section("Location & Details") { Section("Location & Details") {
TextField( TextField(
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'")) "Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
@@ -304,18 +301,30 @@ struct AddEditProblemView: View {
@ViewBuilder @ViewBuilder
private func PhotosSection() -> some View { private func PhotosSection() -> some View {
Section("Photos") { Section("Photos (Optional)") {
PhotosPicker( PhotosPicker(
selection: $selectedPhotos, selection: $selectedPhotos,
maxSelectionCount: 5, maxSelectionCount: 5,
matching: .images matching: .images
) { ) {
HStack { HStack {
Image(systemName: "photo.on.rectangle.angled") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
Text("Add Photos (\(imageData.count)/5)") .font(.title2)
Spacer() VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
} }
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
} }
if !imageData.isEmpty { if !imageData.isEmpty {
@@ -323,13 +332,14 @@ struct AddEditProblemView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) { if let uiImage = UIImage(data: imageData[index]) {
ZStack(alignment: .topTrailing) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.clipped() .clipped()
.cornerRadius(8) .cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: { Button(action: {
imageData.remove(at: index) imageData.remove(at: index)
if index < imagePaths.count { if index < imagePaths.count {
@@ -339,9 +349,11 @@ struct AddEditProblemView: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundColor(.red) .foregroundColor(.red)
.background(Circle().fill(.white)) .background(Circle().fill(.white))
.font(.system(size: 18))
} }
.offset(x: 8, y: -8) .offset(x: 4, y: -4)
} }
.frame(width: 88, height: 88) // Extra space for button
} else { } else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3)) .fill(.gray.opacity(0.3))
@@ -354,6 +366,7 @@ struct AddEditProblemView: View {
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
.padding(.vertical, 8)
} }
} }
} }
@@ -399,7 +412,7 @@ struct AddEditProblemView: View {
selectedClimbType = problem.climbType selectedClimbType = problem.climbType
selectedDifficultySystem = problem.difficulty.system selectedDifficultySystem = problem.difficulty.system
difficultyGrade = problem.difficulty.grade difficultyGrade = problem.difficulty.grade
setter = problem.setter ?? ""
location = problem.location ?? "" location = problem.location ?? ""
tags = problem.tags.joined(separator: ", ") tags = problem.tags.joined(separator: ", ")
notes = problem.notes ?? "" notes = problem.notes ?? ""
@@ -409,7 +422,7 @@ struct AddEditProblemView: View {
// Load image data for preview // Load image data for preview
imageData = [] imageData = []
for imagePath in problem.imagePaths { for imagePath in problem.imagePaths {
if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) { if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
imageData.append(data) imageData.append(data)
} }
} }
@@ -468,7 +481,7 @@ struct AddEditProblemView: View {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTags = tags.split(separator: ",").map { let trimmedTags = tags.split(separator: ",").map {
@@ -483,7 +496,7 @@ struct AddEditProblemView: View {
description: trimmedDescription.isEmpty ? nil : trimmedDescription, description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty, difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: imagePaths,
@@ -499,7 +512,7 @@ struct AddEditProblemView: View {
description: trimmedDescription.isEmpty ? nil : trimmedDescription, description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty, difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: imagePaths,

View File

@@ -104,13 +104,31 @@ struct StatCard: View {
struct ProgressChartSection: View { struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale @State private var selectedSystem: DifficultySystem = .vScale
@State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var progressData: [ProgressDataPoint] { private var gradeCountData: [GradeCount] {
calculateProgressOverTime() let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
} }
private var usedSystems: [DifficultySystem] { private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(progressData.map { $0.difficultySystem }) let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
return uniqueSystems.sorted { return uniqueSystems.sorted {
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom] let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
let firstIndex = order.firstIndex(of: $0) ?? order.count let firstIndex = order.firstIndex(of: $0) ?? order.count
@@ -121,13 +139,50 @@ struct ProgressChartSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { Text("Grade Distribution")
Text("Progress Over Time")
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
// Toggles section
HStack {
// Time period toggle
HStack(spacing: 8) {
Button(action: {
showAllTime = true
}) {
Text("All Time")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(showAllTime ? .white : .blue)
}
Button(action: {
showAllTime = false
}) {
Text("7 Days")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(!showAllTime ? .white : .blue)
}
}
Spacer() Spacer()
// Scale selector (only show if multiple systems)
if usedSystems.count > 1 { if usedSystems.count > 1 {
Menu { Menu {
ForEach(usedSystems, id: \.self) { system in ForEach(usedSystems, id: \.self) { system in
@@ -164,24 +219,22 @@ struct ProgressChartSection: View {
} }
} }
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem } let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty { if !filteredData.isEmpty {
LineChartView(data: filteredData, selectedSystem: selectedSystem) BarChartView(data: filteredData)
.frame(height: 200) .frame(height: 200)
Text( Text("Successful climbs by grade")
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session"
)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else { } else {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis") Image(systemName: "chart.bar")
.font(.title) .font(.title)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No progress data available for \(selectedSystem.displayName) system") Text("No data available for \(selectedSystem.displayName) system")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -201,38 +254,125 @@ struct ProgressChartSection: View {
} }
} }
private func calculateProgressOverTime() -> [ProgressDataPoint] { private func calculateGradeCounts() -> [GradeCount] {
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
let problems = dataManager.problems let problems = dataManager.problems
let attempts = dataManager.attempts let attempts = dataManager.attempts
return sessions.compactMap { session in // Filter attempts by time period
let sessionAttempts = attempts.filter { $0.sessionId == session.id } let filteredAttempts: [Attempt]
let attemptedProblemIds = sessionAttempts.map { $0.problemId } if showAllTime {
filteredAttempts = attempts.filter { $0.result.isSuccessful }
} else {
let sevenDaysAgo =
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
filteredAttempts = attempts.filter {
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
}
}
// Get attempted problems
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Group problems by difficulty system // Group by difficulty system and grade
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } var gradeCounts: [String: GradeCount] = [:]
// Create data points for each system used in this session for problem in attemptedProblems {
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in let successfulAttemptsForProblem = filteredAttempts.filter {
guard $0.problemId == problem.id
let highestGradeProblem = systemProblems.max(by: {
$0.difficulty.numericValue < $1.difficulty.numericValue
})
else {
return nil
} }
let count = successfulAttemptsForProblem.count
return ProgressDataPoint( let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
date: session.date,
maxGrade: highestGradeProblem.difficulty.grade, if let existing = gradeCounts[key] {
maxGradeNumeric: highestGradeProblem.difficulty.numericValue, gradeCounts[key] = GradeCount(
climbType: highestGradeProblem.climbType, grade: existing.grade,
difficultySystem: system count: existing.count + count,
gradeNumeric: existing.gradeNumeric,
difficultySystem: existing.difficultySystem
)
} else {
gradeCounts[key] = GradeCount(
grade: problem.difficulty.grade,
count: count,
gradeNumeric: problem.difficulty.numericValue,
difficultySystem: problem.difficulty.system
) )
} }
}.flatMap { $0 } }
return Array(gradeCounts.values)
}
}
struct GradeCount {
let grade: String
let count: Int
let gradeNumeric: Int
let difficultySystem: DifficultySystem
}
struct BarChartView: View {
let data: [GradeCount]
private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
}
private var maxCount: Int {
data.map { $0.count }.max() ?? 1
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
if sortedData.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
VStack(alignment: .leading) {
// Chart area
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
VStack(spacing: 4) {
// Bar
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(
width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
* chartHeight * 0.8
)
.overlay(
Text("\(gradeCount.count)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.opacity(gradeCount.count > 0 ? 1 : 0)
)
// Grade label
Text(gradeCount.grade)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.frame(height: chartHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
} }
} }
@@ -380,139 +520,6 @@ struct RecentActivitySection: View {
} }
} }
struct LineChartView: View {
let data: [ProgressDataPoint]
let selectedSystem: DifficultySystem
private var uniqueGrades: [String] {
if selectedSystem == .custom {
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
return (Int(grade1) ?? 0) > (Int(grade2) ?? 0)
}
} else {
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
let grade1Data = data.first(where: { $0.maxGrade == grade1 })
let grade2Data = data.first(where: { $0.maxGrade == grade2 })
return (grade1Data?.maxGradeNumeric ?? 0)
> (grade2Data?.maxGradeNumeric ?? 0)
}
}
}
private var minGrade: Int {
data.map { $0.maxGradeNumeric }.min() ?? 0
}
private var maxGrade: Int {
data.map { $0.maxGradeNumeric }.max() ?? 1
}
private var gradeRange: Int {
max(maxGrade - minGrade, 1)
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
if data.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
HStack {
// Y-axis labels
VStack {
ForEach(0..<min(5, uniqueGrades.count), id: \.self) { i in
let gradeLabel = i < uniqueGrades.count ? uniqueGrades[i] : ""
Text(gradeLabel)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 30, alignment: .trailing)
if i < min(4, uniqueGrades.count - 1) {
Spacer()
}
}
}
.frame(height: chartHeight)
// Chart area
ZStack {
// Grid lines
ForEach(0..<5) { i in
let y = CGFloat(i) * chartHeight / 4
Rectangle()
.fill(.gray.opacity(0.2))
.frame(height: 0.5)
.offset(y: y - chartHeight / 2)
}
// Line chart
if data.count > 1 {
Path { path in
for (index, point) in data.enumerated() {
let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1)
let normalizedY =
CGFloat(point.maxGradeNumeric - minGrade)
/ CGFloat(gradeRange)
let y = chartHeight - (normalizedY * chartHeight)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(.blue, lineWidth: 2)
}
// Data points
ForEach(data.indices, id: \.self) { index in
let point = data[index]
let x =
data.count == 1
? chartWidth / 2
: CGFloat(index) * chartWidth / CGFloat(data.count - 1)
let normalizedY =
CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange)
let y = chartHeight - (normalizedY * chartHeight)
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
.position(x: x, y: y)
.overlay(
Circle()
.stroke(.white, lineWidth: 2)
.frame(width: 8, height: 8)
.position(x: x, y: y)
)
}
}
.frame(width: chartWidth, height: chartHeight)
}
}
}
.padding()
}
}
struct ProgressDataPoint {
let date: Date
let maxGrade: String
let maxGradeNumeric: Int
let climbType: ClimbType
let difficultySystem: DifficultySystem
}
#Preview { #Preview {
AnalyticsView() AnalyticsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct GymDetailView: View { struct GymDetailView: View {
@@ -60,8 +59,10 @@ struct GymDetailView: View {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if gym != nil { if gym != nil {
Menu { Menu {
Button("Edit Gym") { Button {
// Navigate to edit view // Navigate to edit view
} label: {
Label("Edit Gym", systemImage: "pencil")
} }
Button(role: .destructive) { Button(role: .destructive) {

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct ProblemDetailView: View { struct ProblemDetailView: View {
@@ -64,8 +63,10 @@ struct ProblemDetailView: View {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if problem != nil { if problem != nil {
Menu { Menu {
Button("Edit Problem") { Button {
showingEditProblem = true showingEditProblem = true
} label: {
Label("Edit Problem", systemImage: "pencil")
} }
Button(role: .destructive) { Button(role: .destructive) {
@@ -167,12 +168,6 @@ struct ProblemHeaderCard: View {
.font(.body) .font(.body)
} }
if let setter = problem.setter, !setter.isEmpty {
Text("Set by: \(setter)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if !problem.tags.isEmpty { if !problem.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {

View File

@@ -15,6 +15,18 @@ struct SessionDetailView: View {
dataManager.session(withId: sessionId) dataManager.session(withId: sessionId)
} }
private func startTimer() {
// Update every 5 seconds instead of 1 second for better performance
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private var gym: Gym? { private var gym: Gym? {
guard let session = session else { return nil } guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId) return dataManager.gym(withId: session.gymId)
@@ -35,7 +47,7 @@ struct SessionDetailView: View {
calculateSessionStats() calculateSessionStats()
} }
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var timer: Timer?
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -57,8 +69,11 @@ struct SessionDetailView: View {
} }
.padding() .padding()
} }
.onReceive(timer) { _ in .onAppear {
currentTime = Date() startTimer()
}
.onDisappear {
stopTimer()
} }
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -153,46 +168,14 @@ struct SessionDetailView: View {
let uniqueProblems = Set(attempts.map { $0.problemId }) let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId }) let completedProblems = Set(successfulAttempts.map { $0.problemId })
let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
let boulderRange = gradeRange(for: boulderProblems)
let ropeRange = gradeRange(for: ropeProblems)
return SessionStats( return SessionStats(
totalAttempts: attempts.count, totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count, successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count, uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count, uniqueProblemsCompleted: completedProblems.count
boulderRange: boulderRange,
ropeRange: ropeRange
) )
} }
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
let difficulties = problems.map { $0.difficulty }
// Group by difficulty system first
let groupedBySystem = Dictionary(grouping: difficulties) { $0.system }
// For each system, find the range
let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in
let sortedDifficulties = difficulties.sorted()
guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else {
return nil
}
if min == max {
return min.grade
} else {
return "\(min.grade) - \(max.grade)"
}
}
return ranges.joined(separator: ", ")
}
} }
struct SessionHeaderCard: View { struct SessionHeaderCard: View {
@@ -297,22 +280,8 @@ struct SessionStatsCard: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)") StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)") StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)") StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
} }
// Grade ranges
VStack(alignment: .leading, spacing: 8) {
if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
HStack {
StatItem(label: "Boulder Range", value: boulderRange)
StatItem(label: "Rope Range", value: ropeRange)
}
} else if let singleRange = stats.boulderRange ?? stats.ropeRange {
StatItem(label: "Grade Range", value: singleRange)
.frame(maxWidth: .infinity, alignment: .center)
}
}
} }
} }
.padding() .padding()
@@ -504,8 +473,6 @@ struct SessionStats {
let successfulAttempts: Int let successfulAttempts: Int
let uniqueProblemsAttempted: Int let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int let uniqueProblemsCompleted: Int
let boulderRange: String?
let ropeRange: String?
} }
#Preview { #Preview {

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct GymsView: View { struct GymsView: View {
@@ -49,7 +48,10 @@ struct GymsList: View {
Button { Button {
gymToEdit = gym gymToEdit = gym
} label: { } label: {
Label("Edit", systemImage: "pencil") HStack {
Image(systemName: "pencil")
Text("Edit")
}
} }
.tint(.blue) .tint(.blue)
} }

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct ProblemsView: View { struct ProblemsView: View {
@@ -14,10 +13,9 @@ struct ProblemsView: View {
// Apply search filter // Apply search filter
if !searchText.isEmpty { if !searchText.isEmpty {
filtered = filtered.filter { problem in filtered = filtered.filter { problem in
(problem.name?.localizedCaseInsensitiveContains(searchText) ?? false) return problem.name?.localizedCaseInsensitiveContains(searchText) ?? false
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false) || (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false) || (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false)
|| problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) } || problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) }
} }
} }
@@ -32,7 +30,11 @@ struct ProblemsView: View {
filtered = filtered.filter { $0.gymId == gym.id } filtered = filtered.filter { $0.gymId == gym.id }
} }
return filtered.sorted { $0.updatedAt > $1.updatedAt } // Separate active and inactive problems
let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
return active + inactive
} }
var body: some View { var body: some View {
@@ -196,10 +198,23 @@ struct ProblemsList: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
Button {
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button { Button {
problemToEdit = problem problemToEdit = problem
} label: { } label: {
Label("Edit", systemImage: "pencil") HStack {
Image(systemName: "pencil")
Text("Edit")
}
} }
.tint(.blue) .tint(.blue)
} }
@@ -240,6 +255,7 @@ struct ProblemRow: View {
Text(problem.name ?? "Unnamed Problem") Text(problem.name ?? "Unnamed Problem")
.font(.headline) .font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(problem.isActive ? .primary : .secondary)
Text(gym?.name ?? "Unknown Gym") Text(gym?.name ?? "Unknown Gym")
.font(.subheadline) .font(.subheadline)
@@ -286,7 +302,7 @@ struct ProblemRow: View {
if !problem.imagePaths.isEmpty { if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath) ProblemImageView(imagePath: imagePath)
} }
@@ -296,9 +312,9 @@ struct ProblemRow: View {
} }
if !problem.isActive { if !problem.isActive {
Text("Inactive") Text("Reset / No Longer Set")
.font(.caption) .font(.caption)
.foregroundColor(.red) .foregroundColor(.orange)
.fontWeight(.medium) .fontWeight(.medium)
} }
} }
@@ -372,6 +388,13 @@ struct ProblemImageView: View {
@State private var isLoading = true @State private var isLoading = true
@State private var hasFailed = false @State private var hasFailed = false
private static var imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
return cache
}()
var body: some View { var body: some View {
Group { Group {
if let uiImage = uiImage { if let uiImage = uiImage {
@@ -412,10 +435,22 @@ struct ProblemImageView: View {
return return
} }
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath), if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data) let image = UIImage(data: data)
{ {
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
DispatchQueue.main.async { DispatchQueue.main.async {
self.uiImage = image self.uiImage = image
self.isLoading = false self.isLoading = false

View File

@@ -114,7 +114,7 @@ struct ActiveSessionBanner: View {
@State private var currentTime = Date() @State private var currentTime = Date()
@State private var navigateToDetail = false @State private var navigateToDetail = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var timer: Timer?
var body: some View { var body: some View {
HStack { HStack {
@@ -138,13 +138,12 @@ struct ActiveSessionBanner: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
navigateToDetail = true navigateToDetail = true
} }
Spacer()
Button(action: { Button(action: {
dataManager.endSession(session.id) dataManager.endSession(session.id)
}) { }) {
@@ -155,6 +154,7 @@ struct ActiveSessionBanner: View {
.background(Color.red) .background(Color.red)
.clipShape(Circle()) .clipShape(Circle())
} }
.buttonStyle(PlainButtonStyle())
} }
.padding() .padding()
.background( .background(
@@ -162,18 +162,15 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1)) .fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1) .stroke(.green.opacity(0.3), lineWidth: 1)
) )
.onReceive(timer) { _ in .onAppear {
currentTime = Date() startTimer()
} }
.background( .onDisappear {
NavigationLink( stopTimer()
destination: SessionDetailView(sessionId: session.id), }
isActive: $navigateToDetail .navigationDestination(isPresented: $navigateToDetail) {
) { SessionDetailView(sessionId: session.id)
EmptyView()
} }
.hidden()
)
} }
private func formatDuration(from start: Date, to end: Date) -> String { private func formatDuration(from start: Date, to end: Date) -> String {
@@ -190,6 +187,17 @@ struct ActiveSessionBanner: View {
return String(format: "%ds", seconds) return String(format: "%ds", seconds)
} }
} }
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
} }
struct SessionRow: View { struct SessionRow: View {

View File

@@ -164,15 +164,37 @@ struct ExportDataView: View {
let data: Data let data: Data
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL? @State private var tempFileURL: URL?
@State private var isCreatingFile = true
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
VStack(spacing: 20) { VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up") ProgressView()
.font(.system(size: 60)) .scaleEffect(1.5)
.foregroundColor(.blue) .tint(.blue)
Text("Export Data") Text("Preparing Your Export")
.font(.title2)
.fontWeight(.semibold)
Text("Creating ZIP file with your climbing data and images...")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Ready state
VStack(spacing: 20) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Export Ready!")
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
@@ -201,24 +223,12 @@ struct ExportDataView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.buttonStyle(.plain) .buttonStyle(.plain)
} else {
Button(action: {}) {
Label("Preparing Export...", systemImage: "hourglass")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
} }
.disabled(true)
.padding(.horizontal)
} }
Spacer() Spacer()
} }
}
.padding() .padding()
.navigationTitle("Export") .navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -259,6 +269,9 @@ struct ExportDataView: View {
).first ).first
else { else {
print("Could not access Documents directory") print("Could not access Documents directory")
DispatchQueue.main.async {
self.isCreatingFile = false
}
return return
} }
let fileURL = documentsURL.appendingPathComponent(filename) let fileURL = documentsURL.appendingPathComponent(filename)
@@ -268,9 +281,13 @@ struct ExportDataView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
self.tempFileURL = fileURL self.tempFileURL = fileURL
self.isCreatingFile = false
} }
} catch { } catch {
print("Failed to create export file: \(error)") print("Failed to create export file: \(error)")
DispatchQueue.main.async {
self.isCreatingFile = false
}
} }
} }
} }

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.OpenClimb</string>
</array>
</dict>
</plist>