diff --git a/android/.kotlin/sessions/kotlin-compiler-8439154287983817179.salive b/android/.kotlin/sessions/kotlin-compiler-8439154287983817179.salive deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index be6b465..a777f93 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 24 - versionName = "1.5.0" + versionCode = 25 + versionName = "1.5.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt index f693631..2e40d07 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt @@ -3,13 +3,13 @@ package com.atridad.openclimb.ui.screens import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -43,10 +43,10 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditAttemptDialog( - attempt: Attempt, - problems: List, - onDismiss: () -> Unit, - onAttemptUpdated: (Attempt) -> Unit + attempt: Attempt, + problems: List, + onDismiss: () -> Unit, + onAttemptUpdated: (Attempt) -> Unit ) { var selectedProblem by remember { mutableStateOf(problems.find { it.id == attempt.problemId }) } var selectedResult by remember { mutableStateOf(attempt.result) } @@ -56,61 +56,59 @@ fun EditAttemptDialog( Dialog(onDismissRequest = onDismiss) { Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { Text( - text = "Edit Attempt", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + text = "Edit Attempt", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold ) // Problem Selection Text( - text = "Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = "Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) if (problems.isEmpty()) { Text( - text = "No problems available.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + text = "No problems available.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error ) } else { var expanded by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( - value = selectedProblem?.name ?: "Unknown Problem", - onValueChange = {}, - readOnly = true, - label = { Text("Problem") }, - trailingIcon = { - Icon( - if (expanded) Icons.Default.KeyboardArrowUp - else Icons.Default.KeyboardArrowDown, - contentDescription = "Toggle dropdown" - ) - }, - modifier = Modifier.fillMaxWidth().clickable { expanded = true } + value = selectedProblem?.name ?: "Unknown Problem", + onValueChange = {}, + readOnly = true, + label = { Text("Problem") }, + trailingIcon = { + Icon( + if (expanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = "Toggle dropdown" + ) + }, + modifier = Modifier.fillMaxWidth().clickable { expanded = true } ) DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.fillMaxWidth(0.9f) + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(0.9f) ) { problems.forEach { problem -> DropdownMenuItem( - text = { Text(problem.name ?: "Unknown Problem") }, - onClick = { - selectedProblem = problem - expanded = false - } + text = { Text(problem.name ?: "Unknown Problem") }, + onClick = { + selectedProblem = problem + expanded = false + } ) } } @@ -119,38 +117,39 @@ fun EditAttemptDialog( // Result Selection Text( - text = "Result", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = "Result", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { AttemptResult.entries.forEach { result -> Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedResult == result, - onClick = { selectedResult = result }, - role = Role.RadioButton - ) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = selectedResult == result, + onClick = { selectedResult = result }, + role = Role.RadioButton + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - RadioButton( - selected = selectedResult == result, - onClick = null - ) + RadioButton(selected = selectedResult == result, onClick = null) Spacer(modifier = Modifier.width(8.dp)) Text( - text = when (result) { - AttemptResult.NO_PROGRESS -> "No Progress" - else -> result.name.lowercase().replaceFirstChar { it.uppercase() } - }, - style = MaterialTheme.typography.bodyMedium + text = + when (result) { + AttemptResult.NO_PROGRESS -> "No Progress" + else -> + result.name.lowercase().replaceFirstChar { + it.uppercase() + } + }, + style = MaterialTheme.typography.bodyMedium ) } } @@ -158,51 +157,49 @@ fun EditAttemptDialog( // Highest Hold OutlinedTextField( - value = highestHold, - onValueChange = { highestHold = it }, - label = { Text("Highest Hold (Optional)") }, - placeholder = { Text("e.g., 'jug on the left'") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true + value = highestHold, + onValueChange = { highestHold = it }, + label = { Text("Highest Hold (Optional)") }, + placeholder = { Text("e.g., 'jug on the left'") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) // Notes OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - label = { Text("Notes (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") } + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") } ) // Buttons Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { Text("Cancel") } Button( - onClick = { - selectedProblem?.let { problem -> - val updatedAttempt = - attempt.copy( - problemId = problem.id, - result = selectedResult, - highestHold = highestHold.ifBlank { null }, - notes = notes.ifBlank { null } - ) - onAttemptUpdated(updatedAttempt) - } - }, - enabled = selectedProblem != null, - modifier = Modifier.weight(1f) - ) { - Text("Update") - } + onClick = { + selectedProblem?.let { problem -> + val updatedAttempt = + attempt.copy( + problemId = problem.id, + result = selectedResult, + highestHold = highestHold.ifBlank { null }, + notes = notes.ifBlank { null } + ) + onAttemptUpdated(updatedAttempt) + } + }, + enabled = selectedProblem != null, + modifier = Modifier.weight(1f) + ) { Text("Update") } } } } @@ -212,10 +209,10 @@ fun EditAttemptDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionDetailScreen( - sessionId: String, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit, - onNavigateToProblemDetail: (String) -> Unit = {} + sessionId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToProblemDetail: (String) -> Unit = {} ) { val context = LocalContext.current val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList()) @@ -234,111 +231,123 @@ fun SessionDetailScreen( // Calculate stats val successfulAttempts = - attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } + attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } val uniqueProblems = attempts.map { it.problemId }.distinct() val attemptedProblems = problems.filter { it.id in uniqueProblems } val completedProblems = successfulAttempts.map { it.problemId }.distinct() val attemptsWithProblems = - attempts - .mapNotNull { attempt -> - val problem = problems.find { it.id == attempt.problemId } - if (problem != null) attempt to problem else null - } - .sortedBy { attempt -> - // Sort by timestamp (when attempt was logged) - attempt.first.timestamp - } + attempts + .mapNotNull { attempt -> + val problem = problems.find { it.id == attempt.problemId } + if (problem != null) attempt to problem else null + } + .sortedBy { attempt -> + // Sort by timestamp (when attempt was logged) + attempt.first.timestamp + } Scaffold( - topBar = { - TopAppBar( - title = { Text("Session Details") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - // Share button - if (session?.duration != null) { // Only show for completed sessions - IconButton( - onClick = { - isGeneratingShare = true - viewModel.viewModelScope.launch { - val shareFile = - viewModel.generateSessionShareCard(context, sessionId) - isGeneratingShare = false - shareFile?.let { file -> - viewModel.shareSessionCard(context, file) + topBar = { + TopAppBar( + title = { Text("Session Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + // Share button + if (session?.duration != null) { // Only show for completed sessions + IconButton( + onClick = { + isGeneratingShare = true + viewModel.viewModelScope.launch { + val shareFile = + viewModel.generateSessionShareCard( + context, + sessionId + ) + isGeneratingShare = false + shareFile?.let { file -> + viewModel.shareSessionCard(context, file) + } + } + }, + enabled = !isGeneratingShare + ) { + if (isGeneratingShare) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share Session" + ) } } - }, - enabled = !isGeneratingShare - ) { - if (isGeneratingShare) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share Session" - ) } - } - } - // Show stop icon for active sessions, delete icon for completed sessions - if (session?.status == SessionStatus.ACTIVE) { - IconButton(onClick = { - session.let { s -> - viewModel.endSession(context, s.id) - onNavigateBack() + // Show stop icon for active sessions, delete icon for completed + // sessions + if (session?.status == SessionStatus.ACTIVE) { + IconButton( + onClick = { + session.let { s -> + viewModel.endSession(context, s.id) + onNavigateBack() + } + } + ) { + Icon( + imageVector = + CustomIcons.Stop( + MaterialTheme.colorScheme.onSurface + ), + contentDescription = "Stop Session" + ) + } + } else { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } } - }) { - Icon( - imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onSurface), - contentDescription = "Stop Session" - ) - } - } else { - IconButton(onClick = { showDeleteDialog = true }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") } + ) + }, + floatingActionButton = { + if (session?.status == SessionStatus.ACTIVE) { + FloatingActionButton(onClick = { showAddAttemptDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Add Attempt") } } - ) - }, - floatingActionButton = { - if (session?.status == SessionStatus.ACTIVE) { - FloatingActionButton(onClick = { showAddAttemptDialog = true }) { - Icon(Icons.Default.Add, contentDescription = "Add Attempt") - } } - } ) { paddingValues -> LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Session Header item { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = gym?.name ?: "Unknown Gym", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = gym?.name ?: "Unknown Gym", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = formatDate(session?.date ?: ""), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary + text = formatDate(session?.date ?: ""), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary ) session?.let { s -> @@ -348,9 +357,9 @@ fun SessionDetailScreen( val timeText = "Duration: ${s.duration} minutes" Text( - text = timeText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = timeText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -364,22 +373,24 @@ fun SessionDetailScreen( Spacer(modifier = Modifier.height(12.dp)) Surface( - color = - if (session?.duration != null) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(12.dp) + color = + if (session?.duration != null) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(12.dp) ) { Text( - text = - if (session?.duration != null) "Completed" else "In Progress", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = - if (session?.duration != null) - MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Medium + text = + if (session?.duration != null) "Completed" + else "In Progress", + modifier = + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = + if (session?.duration != null) + MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium ) } } @@ -391,92 +402,43 @@ fun SessionDetailScreen( Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Session Stats", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Session Stats", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(16.dp)) if (attempts.isEmpty()) { Text( - text = "No attempts recorded yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No attempts recorded yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { StatItem(label = "Total Attempts", value = attempts.size.toString()) StatItem(label = "Problems", value = uniqueProblems.size.toString()) StatItem( - label = "Successful", - value = successfulAttempts.size.toString() + label = "Successful", + value = successfulAttempts.size.toString() ) } Spacer(modifier = Modifier.height(16.dp)) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { StatItem( - label = "Completed", - value = completedProblems.size.toString() + label = "Completed", + 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" - ) - } - } } } } @@ -485,9 +447,9 @@ fun SessionDetailScreen( // Attempts List item { Text( - text = "Attempts (${attempts.size})", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Attempts (${attempts.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) } @@ -495,19 +457,19 @@ fun SessionDetailScreen( item { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "No attempts yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No attempts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Start attempting problems to see your progress!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Start attempting problems to see your progress!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -516,13 +478,15 @@ fun SessionDetailScreen( items(attemptsWithProblems.size) { index -> val (attempt, problem) = attemptsWithProblems[index] SessionAttemptCard( - attempt = attempt, - problem = problem, - onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit }, - onDeleteAttempt = { attemptToDelete -> - viewModel.deleteAttempt(attemptToDelete) - }, - onAttemptClick = { onNavigateToProblemDetail(problem.id) } + attempt = attempt, + problem = problem, + onEditAttempt = { attemptToEdit -> + showEditAttemptDialog = attemptToEdit + }, + onDeleteAttempt = { attemptToDelete -> + viewModel.deleteAttempt(attemptToDelete) + }, + onAttemptClick = { onNavigateToProblemDetail(problem.id) } ) } } @@ -532,62 +496,61 @@ fun SessionDetailScreen( // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Session") }, - text = { - Column { - Text("Are you sure you want to delete this session?") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This will also delete all attempts associated with this session.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - }, - confirmButton = { - TextButton( - onClick = { - session?.let { s -> - viewModel.deleteSession(s) - onNavigateBack() - } - showDeleteDialog = false + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Session") }, + text = { + Column { + Text("Are you sure you want to delete this session?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "This will also delete all attempts associated with this session.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton( + onClick = { + session?.let { s -> + viewModel.deleteSession(s) + onNavigateBack() + } + showDeleteDialog = false + } + ) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } - } ) } if (showAddAttemptDialog && session != null && gym != null) { EnhancedAddAttemptDialog( - session = session, - gym = gym, - problems = problems.filter { it.gymId == gym.id && it.isActive }, - onDismiss = { showAddAttemptDialog = false }, - onAttemptAdded = { attempt -> - viewModel.addAttempt(attempt) - showAddAttemptDialog = false - }, - onProblemCreated = { problem -> viewModel.addProblem(problem) } + session = session, + gym = gym, + problems = problems.filter { it.gymId == gym.id && it.isActive }, + onDismiss = { showAddAttemptDialog = false }, + onAttemptAdded = { attempt -> + viewModel.addAttempt(attempt) + showAddAttemptDialog = false + }, + onProblemCreated = { problem -> viewModel.addProblem(problem) } ) } // Edit attempt dialog showEditAttemptDialog?.let { attempt -> EditAttemptDialog( - attempt = attempt, - problems = problems.filter { it.isActive }, - onDismiss = { showEditAttemptDialog = null }, - onAttemptUpdated = { updatedAttempt -> - viewModel.updateAttempt(updatedAttempt) - showEditAttemptDialog = null - } + attempt = attempt, + problems = problems.filter { it.isActive }, + onDismiss = { showEditAttemptDialog = null }, + onAttemptUpdated = { updatedAttempt -> + viewModel.updateAttempt(updatedAttempt) + showEditAttemptDialog = null + } ) } } @@ -595,10 +558,10 @@ fun SessionDetailScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProblemDetailScreen( - problemId: String, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit, - onNavigateToEdit: (String) -> Unit + problemId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit ) { val context = LocalContext.current var showDeleteDialog by remember { mutableStateOf(false) } @@ -617,73 +580,76 @@ fun ProblemDetailScreen( // Calculate stats val successfulAttempts = - attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } + attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } val attemptsWithSessions = - attempts - .mapNotNull { attempt -> - val session = sessions.find { it.id == attempt.sessionId } - if (session != null) attempt to session else null - } - .sortedByDescending { it.second.date } + attempts + .mapNotNull { attempt -> + val session = sessions.find { it.id == attempt.sessionId } + if (session != null) attempt to session else null + } + .sortedByDescending { it.second.date } Scaffold( - topBar = { - TopAppBar( - title = { Text("Problem Details") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { showDeleteDialog = true }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } - IconButton(onClick = { onNavigateToEdit(problemId) }) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - } - ) - } + topBar = { + TopAppBar( + title = { Text("Problem Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + IconButton(onClick = { onNavigateToEdit(problemId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + ) + } ) { paddingValues -> LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Problem Header item { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = problem?.name ?: "Unknown Problem", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = problem?.name ?: "Unknown Problem", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column { problem?.let { p -> Text( - text = - "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold + text = + "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold ) } problem?.let { p -> Text( - text = p.climbType.getDisplayName(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = p.climbType.getDisplayName(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -691,16 +657,16 @@ fun ProblemDetailScreen( gym?.let { g -> Column(horizontalAlignment = Alignment.End) { Text( - text = g.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = g.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) problem?.location?.let { location -> Text( - text = location, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -717,12 +683,12 @@ fun ProblemDetailScreen( if (p.imagePaths.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) ImageDisplaySection( - imagePaths = p.imagePaths, - title = "Photos", - onImageClick = { index -> - selectedImageIndex = index - showImageViewer = true - } + imagePaths = p.imagePaths, + title = "Photos", + onImageClick = { index -> + selectedImageIndex = index + showImageViewer = true + } ) } } @@ -730,9 +696,9 @@ 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 + text = "Set by: $setter", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -748,9 +714,9 @@ fun ProblemDetailScreen( problem?.notes?.let { notes -> Spacer(modifier = Modifier.height(12.dp)) Text( - text = notes, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -762,28 +728,28 @@ fun ProblemDetailScreen( Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Progress Summary", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Progress Summary", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(16.dp)) if (attempts.isEmpty()) { Text( - text = "No attempts recorded yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No attempts recorded yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { StatItem(label = "Total Attempts", value = attempts.size.toString()) StatItem( - label = "Successful", - value = successfulAttempts.size.toString() + label = "Successful", + value = successfulAttempts.size.toString() ) } @@ -791,16 +757,16 @@ fun ProblemDetailScreen( if (successfulAttempts.isNotEmpty()) { val firstSuccess = - successfulAttempts.minByOrNull { attempt -> - sessions.find { it.id == attempt.sessionId }?.date ?: "" - } + successfulAttempts.minByOrNull { attempt -> + sessions.find { it.id == attempt.sessionId }?.date ?: "" + } firstSuccess?.let { attempt -> val session = sessions.find { it.id == attempt.sessionId } Text( - text = - "First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + text = + "First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary ) } } @@ -812,9 +778,9 @@ fun ProblemDetailScreen( // Attempt History item { Text( - text = "Attempt History (${attempts.size})", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Attempt History (${attempts.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) } @@ -822,19 +788,20 @@ fun ProblemDetailScreen( item { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "No attempts yet", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No attempts yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Start a session and track your attempts on this problem!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = + "Start a session and track your attempts on this problem!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -851,35 +818,34 @@ fun ProblemDetailScreen( // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Problem") }, - text = { - Column { - Text("Are you sure you want to delete this problem?") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This will also delete all attempts associated with this problem.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - }, - confirmButton = { - TextButton( - onClick = { - problem?.let { p -> - viewModel.deleteProblem(p, context) - onNavigateBack() - } - showDeleteDialog = false + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Problem") }, + text = { + Column { + Text("Are you sure you want to delete this problem?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "This will also delete all attempts associated with this problem.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton( + onClick = { + problem?.let { p -> + viewModel.deleteProblem(p, context) + onNavigateBack() + } + showDeleteDialog = false + } + ) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } - } ) } @@ -887,9 +853,9 @@ fun ProblemDetailScreen( problem?.let { p -> if (showImageViewer && p.imagePaths.isNotEmpty()) { FullscreenImageViewer( - imagePaths = p.imagePaths, - initialIndex = selectedImageIndex, - onDismiss = { showImageViewer = false } + imagePaths = p.imagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } ) } } @@ -898,12 +864,12 @@ fun ProblemDetailScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun GymDetailScreen( - gymId: String, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit, - onNavigateToEdit: (String) -> Unit, - onNavigateToSessionDetail: (String) -> Unit = {}, - onNavigateToProblemDetail: (String) -> Unit = {} + gymId: String, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit, + onNavigateToEdit: (String) -> Unit, + onNavigateToSessionDetail: (String) -> Unit = {}, + onNavigateToProblemDetail: (String) -> Unit = {} ) { val gyms by viewModel.gyms.collectAsState() val gym = gyms.find { it.id == gymId } @@ -913,9 +879,9 @@ fun GymDetailScreen( // Calculate statistics val gymAttempts = - allAttempts.filter { attempt -> - problems.any { problem -> problem.id == attempt.problemId } - } + allAttempts.filter { attempt -> + problems.any { problem -> problem.id == attempt.problemId } + } val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size val totalSessions = sessions.size @@ -924,53 +890,54 @@ fun GymDetailScreen( var showDeleteDialog by remember { mutableStateOf(false) } Scaffold( - topBar = { - TopAppBar( - title = { Text(gym?.name ?: "Gym Details") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { showDeleteDialog = true }) { - Icon(Icons.Default.Delete, contentDescription = "Delete") - } - IconButton(onClick = { onNavigateToEdit(gymId) }) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - } - ) - } + topBar = { + TopAppBar( + title = { Text(gym?.name ?: "Gym Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + IconButton(onClick = { onNavigateToEdit(gymId) }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + ) + } ) { paddingValues -> if (gym == null) { Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text("Gym not found") - } + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentAlignment = Alignment.Center + ) { Text("Gym not found") } } else { LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Gym Information Card item { Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = gym.name, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = gym.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) if (gym.location?.isNotBlank() == true) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = gym.location, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = gym.location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -987,91 +954,90 @@ fun GymDetailScreen( Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Statistics", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Statistics", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(16.dp)) // Statistics Grid Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = problems.size.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = problems.size.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = "Problems", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Problems", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = totalSessions.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = totalSessions.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = "Sessions", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } Spacer(modifier = Modifier.height(12.dp)) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = gymAttempts.size.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = gymAttempts.size.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = "Total Attempts", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Total Attempts", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = uniqueProblemsClimbed.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = uniqueProblemsClimbed.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = "Problems Climbed", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Problems Climbed", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } if (activeSessions > 0) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = activeSessions.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = activeSessions.toString(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = "Active Sessions", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Active Sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1084,78 +1050,87 @@ fun GymDetailScreen( if (problems.isNotEmpty()) { item { Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Problems (${problems.size})", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Problems (${problems.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(12.dp)) // Show recent problems (limit to 5) - problems - .sortedByDescending { it.createdAt } - .take(5) - .forEach { problem -> - val problemAttempts = + problems.sortedByDescending { it.createdAt }.take(5).forEach { + problem -> + val problemAttempts = gymAttempts.filter { it.problemId == problem.id } - val problemSuccessful = + val problemSuccessful = problemAttempts.any { it.result in - listOf( - AttemptResult.SUCCESS, - AttemptResult.FLASH - ) + listOf( + AttemptResult.SUCCESS, + AttemptResult.FLASH + ) } - Card( + Card( modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { onNavigateToProblemDetail(problem.id) }, + Modifier.fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onNavigateToProblemDetail( + problem.id + ) + }, colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme + .surface + ), shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - ListItem( + elevation = + CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + ListItem( headlineContent = { Text( - text = problem.name ?: "Unnamed Problem", - fontWeight = FontWeight.Medium + text = problem.name + ?: "Unnamed Problem", + fontWeight = FontWeight.Medium ) }, supportingContent = { Text( - "${problem.difficulty.grade} โ€ข ${problem.climbType} โ€ข ${problemAttempts.size} attempts" + "${problem.difficulty.grade} โ€ข ${problem.climbType} โ€ข ${problemAttempts.size} attempts" ) }, trailingContent = { if (problemSuccessful) { Icon( - Icons.Default.Check, - contentDescription = "Completed", - tint = MaterialTheme.colorScheme.primary + Icons.Default.Check, + contentDescription = "Completed", + tint = + MaterialTheme.colorScheme + .primary ) } } - ) - } + ) } + } if (problems.size > 5) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "... and ${problems.size - 5} more problems", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "... and ${problems.size - 5} more problems", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1167,70 +1142,76 @@ fun GymDetailScreen( if (sessions.isNotEmpty()) { item { Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "Recent Sessions (${sessions.size})", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Recent Sessions (${sessions.size})", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(12.dp)) // Show recent sessions (limit to 3) - sessions - .sortedByDescending { it.date } - .take(3) - .forEach { session -> - val sessionAttempts = + sessions.sortedByDescending { it.date }.take(3).forEach { session -> + val sessionAttempts = gymAttempts.filter { it.sessionId == session.id } - Card( + Card( modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { onNavigateToSessionDetail(session.id) }, + Modifier.fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { + onNavigateToSessionDetail( + session.id + ) + }, colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme + .surface + ), shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - ListItem( + elevation = + CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + ListItem( headlineContent = { Row( - horizontalArrangement = - Arrangement.spacedBy(8.dp), - verticalAlignment = - Alignment.CenterVertically + horizontalArrangement = + Arrangement.spacedBy(8.dp), + verticalAlignment = + Alignment.CenterVertically ) { Text( - text = - if ( - session.status == - SessionStatus.ACTIVE - ) - "Active Session" - else "Session", - fontWeight = FontWeight.Medium + text = + if (session.status == + SessionStatus + .ACTIVE + ) + "Active Session" + else "Session", + fontWeight = FontWeight.Medium ) - if ( - session.status == SessionStatus.ACTIVE + if (session.status == SessionStatus.ACTIVE ) { Badge( - containerColor = - MaterialTheme.colorScheme - .primary + containerColor = + MaterialTheme + .colorScheme + .primary ) { Text( - "ACTIVE", - style = - MaterialTheme.typography - .labelSmall + "ACTIVE", + style = + MaterialTheme + .typography + .labelSmall ) } } @@ -1238,44 +1219,46 @@ fun GymDetailScreen( }, supportingContent = { val dateTime = - try { - LocalDateTime.parse(session.date) - } catch (_: Exception) { - null - } + try { + LocalDateTime.parse(session.date) + } catch (_: Exception) { + null + } val formattedDate = - dateTime?.format( - DateTimeFormatter.ofPattern( - "MMM dd, yyyy" + dateTime?.format( + DateTimeFormatter.ofPattern( + "MMM dd, yyyy" + ) ) - ) ?: session.date + ?: session.date Text( - "$formattedDate โ€ข ${sessionAttempts.size} attempts" + "$formattedDate โ€ข ${sessionAttempts.size} attempts" ) }, trailingContent = { session.duration?.let { duration -> Text( - text = "${duration}min", - style = - MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme - .onSurfaceVariant + text = "${duration}min", + style = + MaterialTheme.typography + .bodySmall, + color = + MaterialTheme.colorScheme + .onSurfaceVariant ) } } - ) - } + ) } + } if (sessions.size > 3) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "... and ${sessions.size - 3} more sessions", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "... and ${sessions.size - 3} more sessions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1287,23 +1270,23 @@ fun GymDetailScreen( if (problems.isEmpty() && sessions.isEmpty()) { item { Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier.fillMaxWidth().padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "No activity yet", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = "No activity yet", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Start a session or add problems to see them here", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Start a session or add problems to see them here", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1316,36 +1299,34 @@ fun GymDetailScreen( // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Gym") }, - text = { - Column { - Text("Are you sure you want to delete this gym?") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = - "This will also delete all problems and sessions associated with this gym.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - }, - confirmButton = { - TextButton( - onClick = { - gym?.let { g -> - viewModel.deleteGym(g) - onNavigateBack() - } - showDeleteDialog = false + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Gym") }, + text = { + Column { + Text("Are you sure you want to delete this gym?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "This will also delete all problems and sessions associated with this gym.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton( + onClick = { + gym?.let { g -> + viewModel.deleteGym(g) + onNavigateBack() + } + showDeleteDialog = false + } + ) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } - } ) } } @@ -1354,15 +1335,15 @@ fun GymDetailScreen( fun StatItem(label: String, value: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = value, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1372,21 +1353,21 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column { Text( - text = formatDate(session.date), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = formatDate(session.date), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) gym?.let { g -> Text( - text = g.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = g.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1405,107 +1386,106 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { @Composable fun AttemptResultBadge(result: AttemptResult) { val backgroundColor = - when (result) { - AttemptResult.SUCCESS, - AttemptResult.FLASH -> MaterialTheme.colorScheme.primaryContainer - AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant - } + when (result) { + AttemptResult.SUCCESS, AttemptResult.FLASH -> + MaterialTheme.colorScheme.primaryContainer + AttemptResult.FALL -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } val textColor = - when (result) { - AttemptResult.SUCCESS, - AttemptResult.FLASH -> MaterialTheme.colorScheme.onPrimaryContainer - AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant - } + when (result) { + AttemptResult.SUCCESS, AttemptResult.FLASH -> + MaterialTheme.colorScheme.onPrimaryContainer + AttemptResult.FALL -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Surface(color = backgroundColor, shape = RoundedCornerShape(12.dp)) { Text( - text = when (result) { - AttemptResult.NO_PROGRESS -> "No Progress" - else -> result.name.lowercase().replaceFirstChar { it.uppercase() } - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = textColor, - fontWeight = FontWeight.Medium + text = + when (result) { + AttemptResult.NO_PROGRESS -> "No Progress" + else -> result.name.lowercase().replaceFirstChar { it.uppercase() } + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = textColor, + fontWeight = FontWeight.Medium ) } } @Composable fun SessionAttemptCard( - attempt: Attempt, - problem: Problem, - onEditAttempt: (Attempt) -> Unit = {}, - onDeleteAttempt: (Attempt) -> Unit = {}, - onAttemptClick: () -> Unit = {} + attempt: Attempt, + problem: Problem, + onEditAttempt: (Attempt) -> Unit = {}, + onDeleteAttempt: (Attempt) -> Unit = {}, + onAttemptClick: () -> Unit = {} ) { var showDeleteDialog by remember { mutableStateOf(false) } Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onAttemptClick() }, - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + modifier = Modifier.fillMaxWidth().clickable { onAttemptClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( - text = problem.name ?: "Unknown Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium + text = problem.name ?: "Unknown Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium ) Text( - text = - "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + text = + "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary ) problem.location?.let { location -> Text( - text = location, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { AttemptResultBadge(result = attempt.result) // Edit button IconButton( - onClick = { onEditAttempt(attempt) }, - modifier = Modifier.size(32.dp) + onClick = { onEditAttempt(attempt) }, + modifier = Modifier.size(32.dp) ) { Icon( - Icons.Default.Edit, - contentDescription = "Edit attempt", - modifier = Modifier.size(16.dp) + Icons.Default.Edit, + contentDescription = "Edit attempt", + modifier = Modifier.size(16.dp) ) } // Delete button IconButton( - onClick = { showDeleteDialog = true }, - modifier = Modifier.size(32.dp) + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(32.dp) ) { Icon( - Icons.Default.Delete, - contentDescription = "Delete attempt", - modifier = Modifier.size(16.dp) + Icons.Default.Delete, + contentDescription = "Delete attempt", + modifier = Modifier.size(16.dp) ) } } @@ -1521,22 +1501,20 @@ fun SessionAttemptCard( // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Attempt") }, - text = { Text("Are you sure you want to delete this attempt?") }, - confirmButton = { - TextButton( - onClick = { - onDeleteAttempt(attempt) - showDeleteDialog = false - } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Attempt") }, + text = { Text("Are you sure you want to delete this attempt?") }, + confirmButton = { + TextButton( + onClick = { + onDeleteAttempt(attempt) + showDeleteDialog = false + } + ) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } - } ) } } @@ -1555,12 +1533,12 @@ private fun formatDate(dateString: String): String { @OptIn(ExperimentalMaterial3Api::class) @Composable fun EnhancedAddAttemptDialog( - session: ClimbSession, - gym: Gym, - problems: List, - onDismiss: () -> Unit, - onAttemptAdded: (Attempt) -> Unit, - onProblemCreated: (Problem) -> Unit + session: ClimbSession, + gym: Gym, + problems: List, + onDismiss: () -> Unit, + onAttemptAdded: (Attempt) -> Unit, + onProblemCreated: (Problem) -> Unit ) { var selectedProblem by remember { mutableStateOf(null) } var selectedResult by remember { mutableStateOf(AttemptResult.FALL) } @@ -1578,9 +1556,8 @@ fun EnhancedAddAttemptDialog( // Auto-select climb type if there's only one available LaunchedEffect(gym.supportedClimbTypes) { - if ( - gym.supportedClimbTypes.size == 1 && - selectedClimbType != gym.supportedClimbTypes.first() + if (gym.supportedClimbTypes.size == 1 && + selectedClimbType != gym.supportedClimbTypes.first() ) { selectedClimbType = gym.supportedClimbTypes.first() } @@ -1589,17 +1566,16 @@ fun EnhancedAddAttemptDialog( // Auto-select difficulty system if there's only one available for the selected climb type LaunchedEffect(selectedClimbType, gym.difficultySystems) { val availableSystems = - DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> - gym.difficultySystems.contains(system) - } + DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> + gym.difficultySystems.contains(system) + } when { // If current system is not compatible, select the first available one selectedDifficultySystem !in availableSystems -> { selectedDifficultySystem = - availableSystems.firstOrNull() - ?: gym.difficultySystems.firstOrNull() - ?: DifficultySystem.CUSTOM + availableSystems.firstOrNull() + ?: gym.difficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM } // If there's only one available system, auto-select it availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> { @@ -1617,58 +1593,54 @@ fun EnhancedAddAttemptDialog( } Dialog(onDismissRequest = onDismiss) { - Card( - modifier = Modifier - .fillMaxWidth(0.95f) - .fillMaxHeight(0.9f) - .padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(0.95f).fillMaxHeight(0.9f).padding(16.dp)) { Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { Text( - text = "Add Attempt", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 20.dp) + text = "Add Attempt", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 20.dp) ) LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(20.dp) + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { item { if (!showCreateProblem) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Select Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = "Select Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) if (problems.isEmpty()) { Card( - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = 0.5f - ) - ), - modifier = Modifier.fillMaxWidth() + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme + .surfaceVariant.copy( + alpha = 0.5f + ) + ), + modifier = Modifier.fillMaxWidth() ) { Column( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "No active problems in this gym", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No active problems in this gym", + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme + .onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) @@ -1680,58 +1652,77 @@ fun EnhancedAddAttemptDialog( } } else { LazyColumn( - modifier = Modifier.height(140.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.height(140.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(problems) { problem -> val isSelected = selectedProblem?.id == problem.id Card( - onClick = { selectedProblem = problem }, - colors = - CardDefaults.cardColors( - containerColor = + onClick = { selectedProblem = problem }, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) + MaterialTheme + .colorScheme + .primaryContainer + else + MaterialTheme + .colorScheme + .surfaceVariant, + ), + border = if (isSelected) - MaterialTheme.colorScheme - .primaryContainer - else - MaterialTheme.colorScheme - .surfaceVariant, - ), - border = - if (isSelected) - BorderStroke( - 2.dp, - MaterialTheme.colorScheme.primary - ) - else null, - modifier = Modifier.fillMaxWidth() + BorderStroke( + 2.dp, + MaterialTheme + .colorScheme + .primary + ) + else null, + modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = problem.name ?: "Unnamed Problem", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = - if (isSelected) - MaterialTheme.colorScheme.onSurface - else - MaterialTheme.colorScheme - .onSurfaceVariant + text = problem.name + ?: "Unnamed Problem", + style = + MaterialTheme.typography + .bodyLarge, + fontWeight = FontWeight.SemiBold, + color = + if (isSelected) + MaterialTheme + .colorScheme + .onSurface + else + MaterialTheme + .colorScheme + .onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = - "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", - style = MaterialTheme.typography.bodyMedium, - color = - if (isSelected) - MaterialTheme.colorScheme.onSurface - .copy(alpha = 0.8f) - else - MaterialTheme.colorScheme - .onSurfaceVariant - .copy(alpha = 0.7f), - fontWeight = FontWeight.Medium + text = + "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", + style = + MaterialTheme.typography + .bodyMedium, + color = + if (isSelected) + MaterialTheme + .colorScheme + .onSurface.copy( + alpha = 0.8f + ) + else + MaterialTheme + .colorScheme + .onSurfaceVariant + .copy( + alpha = + 0.7f + ), + fontWeight = FontWeight.Medium ) } } @@ -1740,78 +1731,80 @@ fun EnhancedAddAttemptDialog( // Option to create new problem OutlinedButton( - onClick = { showCreateProblem = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Create New Problem") - } + onClick = { showCreateProblem = true }, + modifier = Modifier.fillMaxWidth() + ) { Text("Create New Problem") } } } } else { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() ) { Text( - text = "Create New Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = "Create New Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) IconButton(onClick = { showCreateProblem = false }) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onSurfaceVariant + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } OutlinedTextField( - value = newProblemName, - onValueChange = { newProblemName = it }, - label = { Text("Problem Name") }, - placeholder = { Text("e.g., 'The Red Overhang'") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline - ) + value = newProblemName, + onValueChange = { newProblemName = it }, + label = { Text("Problem Name") }, + placeholder = { Text("e.g., 'The Red Overhang'") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + MaterialTheme.colorScheme.primary, + unfocusedBorderColor = + MaterialTheme.colorScheme.outline + ) ) // Climb Type Selection Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Climb Type", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + text = "Climb Type", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(gym.supportedClimbTypes) { climbType -> FilterChip( - onClick = { selectedClimbType = climbType }, - label = { - Text( - climbType.getDisplayName(), - fontWeight = FontWeight.Medium - ) - }, - selected = selectedClimbType == climbType, - colors = - FilterChipDefaults.filterChipColors( - selectedContainerColor = - MaterialTheme.colorScheme - .primaryContainer, - selectedLabelColor = - MaterialTheme.colorScheme - .onPrimaryContainer - ) + onClick = { selectedClimbType = climbType }, + label = { + Text( + climbType.getDisplayName(), + fontWeight = FontWeight.Medium + ) + }, + selected = selectedClimbType == climbType, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme + .colorScheme + .primaryContainer, + selectedLabelColor = + MaterialTheme + .colorScheme + .onPrimaryContainer + ) ) } } @@ -1820,38 +1813,40 @@ fun EnhancedAddAttemptDialog( // Difficulty System Selection Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Difficulty System", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + text = "Difficulty System", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { val availableSystems = - DifficultySystem.getSystemsForClimbType( - selectedClimbType - ) - .filter { system -> - gym.difficultySystems.contains(system) - } + DifficultySystem.getSystemsForClimbType( + selectedClimbType + ) + .filter { system -> + gym.difficultySystems.contains(system) + } items(availableSystems) { system -> FilterChip( - onClick = { selectedDifficultySystem = system }, - label = { - Text( - system.getDisplayName(), - fontWeight = FontWeight.Medium - ) - }, - selected = selectedDifficultySystem == system, - colors = - FilterChipDefaults.filterChipColors( - selectedContainerColor = - MaterialTheme.colorScheme - .primaryContainer, - selectedLabelColor = - MaterialTheme.colorScheme - .onPrimaryContainer - ) + onClick = { selectedDifficultySystem = system }, + label = { + Text( + system.getDisplayName(), + fontWeight = FontWeight.Medium + ) + }, + selected = selectedDifficultySystem == system, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme + .colorScheme + .primaryContainer, + selectedLabelColor = + MaterialTheme + .colorScheme + .onPrimaryContainer + ) ) } } @@ -1859,89 +1854,114 @@ fun EnhancedAddAttemptDialog( if (selectedDifficultySystem == DifficultySystem.CUSTOM) { OutlinedTextField( - value = newProblemGrade, - onValueChange = { newValue -> - // Only allow integers for custom scales - if (newValue.isEmpty() || newValue.all { it.isDigit() }) { - newProblemGrade = newValue - } - }, - label = { Text("Grade *") }, - placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = - MaterialTheme.colorScheme.primary, - unfocusedBorderColor = - MaterialTheme.colorScheme.outline - ), - isError = newProblemGrade.isBlank(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - supportingText = - if (newProblemGrade.isBlank()) { - { - Text( - "Grade is required", - color = MaterialTheme.colorScheme.error - ) + value = newProblemGrade, + onValueChange = { newValue -> + // Only allow integers for custom scales + if (newValue.isEmpty() || + newValue.all { it.isDigit() } + ) { + newProblemGrade = newValue } - } else { - { - Text( - "Custom grades must be whole numbers", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + }, + label = { Text("Grade *") }, + placeholder = { + Text("Enter numeric grade (e.g. 5, 10, 15)") + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + MaterialTheme.colorScheme + .primary, + unfocusedBorderColor = + MaterialTheme.colorScheme + .outline + ), + isError = newProblemGrade.isBlank(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number + ), + supportingText = + if (newProblemGrade.isBlank()) { + { + Text( + "Grade is required", + color = + MaterialTheme + .colorScheme + .error + ) + } + } else { + { + Text( + "Custom grades must be whole numbers", + color = + MaterialTheme + .colorScheme + .onSurfaceVariant + ) + } + } ) } else { var expanded by remember { mutableStateOf(false) } val availableGrades = - selectedDifficultySystem.getAvailableGrades() + selectedDifficultySystem.getAvailableGrades() ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth() + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( - value = newProblemGrade, - onValueChange = {}, - readOnly = true, - label = { Text("Grade *") }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded - ) - }, - colors = - ExposedDropdownMenuDefaults - .outlinedTextFieldColors(), - modifier = Modifier.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true).fillMaxWidth(), - isError = newProblemGrade.isBlank(), - supportingText = - if (newProblemGrade.isBlank()) { - { - Text( - "Grade is required", - color = MaterialTheme.colorScheme.error - ) - } - } else null + value = newProblemGrade, + onValueChange = {}, + readOnly = true, + label = { Text("Grade *") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = + ExposedDropdownMenuDefaults + .outlinedTextFieldColors(), + modifier = + Modifier.menuAnchor( + androidx.compose.material3 + .MenuAnchorType + .PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth(), + isError = newProblemGrade.isBlank(), + supportingText = + if (newProblemGrade.isBlank()) { + { + Text( + "Grade is required", + color = + MaterialTheme + .colorScheme + .error + ) + } + } else null ) ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } + expanded = expanded, + onDismissRequest = { expanded = false } ) { availableGrades.forEach { grade -> DropdownMenuItem( - text = { Text(grade) }, - onClick = { - newProblemGrade = grade - expanded = false - } + text = { Text(grade) }, + onClick = { + newProblemGrade = grade + expanded = false + } ) } } @@ -1955,54 +1975,61 @@ fun EnhancedAddAttemptDialog( item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Attempt Result", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = "Attempt Result", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) Card( - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = 0.3f + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) ) - ) ) { Column(modifier = Modifier.padding(12.dp).selectableGroup()) { AttemptResult.entries.forEach { result -> Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .selectable( - selected = selectedResult == result, - onClick = { selectedResult = result }, - role = Role.RadioButton - ) - .padding(vertical = 4.dp) + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = + selectedResult == + result, + onClick = { + selectedResult = result + }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp) ) { RadioButton( - selected = selectedResult == result, - onClick = null, - colors = - RadioButtonDefaults.colors( - selectedColor = - MaterialTheme.colorScheme.primary - ) + selected = selectedResult == result, + onClick = null, + colors = + RadioButtonDefaults.colors( + selectedColor = + MaterialTheme + .colorScheme + .primary + ) ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = - result.name.lowercase().replaceFirstChar { - it.uppercase() - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = - if (selectedResult == result) FontWeight.Medium - else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface + text = + result.name.lowercase() + .replaceFirstChar { + it.uppercase() + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = + if (selectedResult == result) + FontWeight.Medium + else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface ) } } @@ -2011,131 +2038,149 @@ fun EnhancedAddAttemptDialog( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Additional Details", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = "Additional Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) OutlinedTextField( - value = highestHold, - onValueChange = { highestHold = it }, - label = { Text("Highest Hold") }, - placeholder = { - Text("e.g., 'jugs near the top', 'crux move'") - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline - ) + value = highestHold, + onValueChange = { highestHold = it }, + label = { Text("Highest Hold") }, + placeholder = { + Text("e.g., 'jugs near the top', 'crux move'") + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + MaterialTheme.colorScheme.primary, + unfocusedBorderColor = + MaterialTheme.colorScheme.outline + ) ) OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - label = { Text("Notes") }, - placeholder = { - Text("e.g., 'need to work on heel hooks', 'pumped out'") - }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 4, - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline - ) + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes") }, + placeholder = { + Text("e.g., 'need to work on heel hooks', 'pumped out'") + }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 4, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + MaterialTheme.colorScheme.primary, + unfocusedBorderColor = + MaterialTheme.colorScheme.outline + ) ) } Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { OutlinedButton( - onClick = onDismiss, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Text("Cancel", fontWeight = FontWeight.Medium) - } + onClick = onDismiss, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = + MaterialTheme.colorScheme.onSurface + ) + ) { Text("Cancel", fontWeight = FontWeight.Medium) } Button( - onClick = { - if (showCreateProblem) { - // Create new problem first - if (newProblemGrade.isNotBlank()) { - val difficulty = - DifficultyGrade( - system = selectedDifficultySystem, - grade = newProblemGrade, - numericValue = - when (selectedDifficultySystem) { - DifficultySystem.V_SCALE -> - newProblemGrade - .removePrefix("V") - .toIntOrNull() ?: 0 - else -> - newProblemGrade.hashCode() % 100 - } - ) + onClick = { + if (showCreateProblem) { + // Create new problem first + if (newProblemGrade.isNotBlank()) { + val difficulty = + DifficultyGrade( + system = + selectedDifficultySystem, + grade = newProblemGrade, + numericValue = + when (selectedDifficultySystem + ) { + DifficultySystem + .V_SCALE -> + newProblemGrade + .removePrefix( + "V" + ) + .toIntOrNull() + ?: 0 + else -> + newProblemGrade + .hashCode() % + 100 + } + ) - val newProblem = - Problem.create( - gymId = gym.id, - name = newProblemName.ifBlank { null }, - climbType = selectedClimbType, - difficulty = difficulty - ) + val newProblem = + Problem.create( + gymId = gym.id, + name = + newProblemName.ifBlank { + null + }, + climbType = selectedClimbType, + difficulty = difficulty + ) - onProblemCreated(newProblem) + onProblemCreated(newProblem) - // Create attempt for the new problem - val attempt = - Attempt.create( - sessionId = session.id, - problemId = newProblem.id, - result = selectedResult, - highestHold = highestHold.ifBlank { null }, - notes = notes.ifBlank { null } - ) - onAttemptAdded(attempt) + // Create attempt for the new problem + val attempt = + Attempt.create( + sessionId = session.id, + problemId = newProblem.id, + result = selectedResult, + highestHold = + highestHold.ifBlank { + null + }, + notes = notes.ifBlank { null } + ) + onAttemptAdded(attempt) + } + } else { + // Create attempt for selected problem + selectedProblem?.let { problem -> + val attempt = + Attempt.create( + sessionId = session.id, + problemId = problem.id, + result = selectedResult, + highestHold = + highestHold.ifBlank { + null + }, + notes = notes.ifBlank { null } + ) + onAttemptAdded(attempt) + } } - } else { - // Create attempt for selected problem - selectedProblem?.let { problem -> - val attempt = - Attempt.create( - sessionId = session.id, - problemId = problem.id, - result = selectedResult, - highestHold = highestHold.ifBlank { null }, - notes = notes.ifBlank { null } - ) - onAttemptAdded(attempt) - } - } - }, - enabled = - if (showCreateProblem) newProblemGrade.isNotBlank() - else selectedProblem != null, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - disabledContainerColor = - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.12f + }, + enabled = + if (showCreateProblem) newProblemGrade.isNotBlank() + else selectedProblem != null, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = + MaterialTheme.colorScheme.primary, + disabledContainerColor = + MaterialTheme.colorScheme.onSurface + .copy(alpha = 0.12f) ) - ) - ) { - Text("Add", fontWeight = FontWeight.Medium) - } + ) { Text("Add", fontWeight = FontWeight.Medium) } } } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 375935d..024f789 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -324,12 +324,17 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { fun exportDataToZipUri(context: Context, uri: android.net.Uri) { viewModelScope.launch { 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) _uiState.value = _uiState.value.copy( isLoading = false, - message = "Data with images exported successfully" + message = + "Export complete! Your climbing data and images have been saved." ) } catch (e: Exception) { _uiState.value = diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index d03d003..d01e414 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -394,7 +394,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -414,7 +414,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -437,7 +437,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -457,7 +457,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -479,7 +479,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -490,7 +490,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -509,7 +509,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -520,7 +520,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index e8446e6..10fa4a8 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist index 29372fd..53d0216 100644 --- a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ SessionStatusLiveExtension.xcscheme_^#shared#^_ orderHint - 1 + 0 diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index 276c850..d99571e 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -4,6 +4,7 @@ struct ContentView: View { @StateObject private var dataManager = ClimbingDataManager() @State private var selectedTab = 0 @Environment(\.scenePhase) private var scenePhase + @State private var notificationObservers: [NSObjectProtocol] = [] var body: some View { TabView(selection: $selectedTab) { @@ -43,11 +44,23 @@ struct ContentView: View { .tag(4) } .environmentObject(dataManager) - .onChange(of: scenePhase) { - if scenePhase == .active { - dataManager.onAppBecomeActive() + .onChange(of: scenePhase) { oldPhase, newPhase in + 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() + } + } else if newPhase == .background { + dataManager.onAppEnterBackground() } } + .onAppear { + setupNotificationObservers() + } + .onDisappear { + removeNotificationObservers() + } .overlay(alignment: .top) { if let message = dataManager.successMessage { SuccessMessageView(message: message) @@ -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 + dataManager.onAppBecomeActive() + } + } + + notificationObservers = [willEnterForegroundObserver, didBecomeActiveObserver] + } + + private func removeNotificationObservers() { + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + notificationObservers.removeAll() + } } struct SuccessMessageView: View { diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift index eb59c94..a8d74db 100644 --- a/ios/OpenClimb/Utils/ImageManager.swift +++ b/ios/OpenClimb/Utils/ImageManager.swift @@ -1,4 +1,3 @@ - import Foundation 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 relativePath.hasPrefix("/") { // If it's pointing to legacy Documents directory, redirect to new location diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 8370854..cbd55fa 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -7,6 +7,10 @@ import UniformTypeIdentifiers import WidgetKit #endif +#if canImport(ActivityKit) + import ActivityKit +#endif + @MainActor class ClimbingDataManager: ObservableObject { @@ -23,6 +27,7 @@ class ClimbingDataManager: ObservableObject { private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var liveActivityObserver: NSObjectProtocol? private enum Keys { static let gyms = "openclimb_gyms" @@ -57,6 +62,7 @@ class ClimbingDataManager: ObservableObject { _ = ImageManager.shared loadAllData() migrateImagePaths() + setupLiveActivityNotifications() Task { try? await Task.sleep(nanoseconds: 2_000_000_000) @@ -67,6 +73,12 @@ class ClimbingDataManager: ObservableObject { } } + deinit { + if let observer = liveActivityObserver { + NotificationCenter.default.removeObserver(observer) + } + } + private func loadAllData() { loadGyms() loadProblems() @@ -463,6 +475,7 @@ class ClimbingDataManager: ObservableObject { let exportData = ClimbDataExport( exportedAt: dateFormatter.string(from: Date()), + version: "1.0", gyms: gyms.map { AndroidGym(from: $0) }, problems: problems.map { AndroidProblem(from: $0) }, sessions: sessions.map { AndroidClimbSession(from: $0) }, @@ -471,13 +484,21 @@ class ClimbingDataManager: ObservableObject { // Collect referenced image paths let referencedImagePaths = collectReferencedImagePaths() + print("๐ŸŽฏ Starting export with \(referencedImagePaths.count) images") - return try ZipUtils.createExportZip( + let zipData = try ZipUtils.createExportZip( exportData: exportData, referencedImagePaths: referencedImagePaths ) + + print("โœ… Export completed successfully") + successMessage = "Export completed with \(referencedImagePaths.count) images" + clearMessageAfterDelay() + return zipData } catch { - setError("Export failed: \(error.localizedDescription)") + let errorMessage = "Export failed: \(error.localizedDescription)" + print("โŒ \(errorMessage)") + setError(errorMessage) return nil } } @@ -565,16 +586,18 @@ class ClimbingDataManager: ObservableObject { struct ClimbDataExport: Codable { let exportedAt: String + let version: String let gyms: [AndroidGym] let problems: [AndroidProblem] let sessions: [AndroidClimbSession] let attempts: [AndroidAttempt] init( - exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem], + exportedAt: String, version: String = "1.0", gyms: [AndroidGym], problems: [AndroidProblem], sessions: [AndroidClimbSession], attempts: [AndroidAttempt] ) { self.exportedAt = exportedAt + self.version = version self.gyms = gyms self.problems = problems self.sessions = sessions @@ -588,6 +611,7 @@ struct AndroidGym: Codable { let location: String? let supportedClimbTypes: [ClimbType] let difficultySystems: [DifficultySystem] + let customDifficultyGrades: [String] let notes: String? let createdAt: String let updatedAt: String @@ -598,6 +622,7 @@ struct AndroidGym: Codable { self.location = gym.location self.supportedClimbTypes = gym.supportedClimbTypes self.difficultySystems = gym.difficultySystems + self.customDifficultyGrades = gym.customDifficultyGrades self.notes = gym.notes let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" @@ -607,13 +632,15 @@ struct AndroidGym: Codable { init( id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], - difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String + 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 @@ -633,7 +660,7 @@ struct AndroidGym: Codable { location: location, supportedClimbTypes: supportedClimbTypes, difficultySystems: difficultySystems, - customDifficultyGrades: [], + customDifficultyGrades: customDifficultyGrades, notes: notes, createdAt: createdDate, updatedAt: updatedDate @@ -648,7 +675,13 @@ struct AndroidProblem: Codable { let description: String? let climbType: ClimbType let difficulty: DifficultyGrade + let setter: String? + let tags: [String] + let location: String? let imagePaths: [String]? + let isActive: Bool + let dateSet: String? + let notes: String? let createdAt: String let updatedAt: String @@ -659,16 +692,26 @@ struct AndroidProblem: Codable { self.description = problem.description self.climbType = problem.climbType self.difficulty = problem.difficulty + self.setter = problem.setter + 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 = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil 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 + difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [], + location: String? = nil, + imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil, + notes: String? = nil, + createdAt: String, updatedAt: String ) { self.id = id self.gymId = gymId @@ -676,7 +719,13 @@ struct AndroidProblem: Codable { self.description = description self.climbType = climbType self.difficulty = difficulty + self.setter = setter + self.tags = tags + self.location = location self.imagePaths = imagePaths + self.isActive = isActive + self.dateSet = dateSet + self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } @@ -697,13 +746,13 @@ struct AndroidProblem: Codable { description: description, climbType: climbType, difficulty: difficulty, - setter: nil, - tags: [], - location: nil, + setter: setter, + tags: tags, + location: location, imagePaths: imagePaths ?? [], - isActive: true, - dateSet: nil, - notes: nil, + isActive: isActive, + dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil, + notes: notes, createdAt: createdDate, updatedAt: updatedDate ) @@ -717,7 +766,13 @@ struct AndroidProblem: Codable { description: self.description, climbType: self.climbType, difficulty: self.difficulty, + setter: self.setter, + 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 ) @@ -730,8 +785,9 @@ struct AndroidClimbSession: Codable { let date: String let startTime: String? let endTime: String? - let duration: Int? + let duration: Int64? let status: SessionStatus + let notes: String? let createdAt: String let updatedAt: String @@ -743,15 +799,17 @@ struct AndroidClimbSession: Codable { 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.duration = session.duration != nil ? Int64(session.duration!) : nil self.status = session.status + self.notes = session.notes 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 + duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String, + updatedAt: String ) { self.id = id self.gymId = gymId @@ -760,6 +818,7 @@ struct AndroidClimbSession: Codable { self.endTime = endTime self.duration = duration self.status = status + self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } @@ -783,9 +842,9 @@ struct AndroidClimbSession: Codable { date: sessionDate, startTime: sessionStartTime, endTime: sessionEndTime, - duration: duration, + duration: duration != nil ? Int(duration!) : nil, status: status, - notes: nil, + notes: notes, createdAt: createdDate, updatedAt: updatedDate ) @@ -799,8 +858,8 @@ struct AndroidAttempt: Codable { let result: AttemptResult let highestHold: String? let notes: String? - let duration: Int? - let restTime: Int? + let duration: Int64? + let restTime: Int64? let timestamp: String let createdAt: String @@ -811,8 +870,8 @@ struct AndroidAttempt: Codable { self.result = attempt.result self.highestHold = attempt.highestHold self.notes = attempt.notes - self.duration = attempt.duration - self.restTime = attempt.restTime + self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil + self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" self.timestamp = formatter.string(from: attempt.timestamp) @@ -821,7 +880,7 @@ struct AndroidAttempt: Codable { init( id: String, sessionId: String, problemId: String, result: AttemptResult, - highestHold: String?, notes: String?, duration: Int?, restTime: Int?, + highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?, timestamp: String, createdAt: String ) { self.id = id @@ -853,8 +912,8 @@ struct AndroidAttempt: Codable { result: result, highestHold: highestHold, notes: notes, - duration: duration, - restTime: restTime, + duration: duration != nil ? Int(duration!) : nil, + restTime: restTime != nil ? Int(restTime!) : nil, timestamp: attemptTimestamp, createdAt: createdDate ) @@ -864,9 +923,33 @@ struct AndroidAttempt: Codable { extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() + print("๐Ÿ–ผ๏ธ Starting image path collection...") + print("๐Ÿ“Š Total problems: \(problems.count)") + 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 } @@ -1046,23 +1129,111 @@ extension ClimbingDataManager { } 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) { + 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( activeSession: activeSession, gymName: gym.name ) + + // Update with current session data + await updateLiveActivityData() } } /// Call this when app becomes active to check for Live Activity restart func onAppBecomeActive() { + print("๐Ÿ“ฑ App became active - checking Live Activity status") Task { 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 private func updateLiveActivityForActiveSession() { guard let activeSession = activeSession, diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index 2598e42..c6e7992 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -1,12 +1,22 @@ import ActivityKit import Foundation +extension Notification.Name { + static let liveActivityDismissed = Notification.Name("liveActivityDismissed") +} + @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} private var currentActivity: Activity? + 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 func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { @@ -18,13 +28,31 @@ final class LiveActivityManager { return } - // Check if we already have a running Live Activity - if currentActivity != nil { - print("โ„น๏ธ Live Activity already running") + // Check if we have a tracked Live Activity that's still actually running + if let currentActivity = currentActivity { + let activities = Activity.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.activities + if let existingActivity = existingActivities.first { + print("โ„น๏ธ Found existing Live Activity: \(existingActivity.id), using it") + self.currentActivity = existingActivity return } - print("๐Ÿ”„ Restarting Live Activity for existing session") + print("๐Ÿ”„ No Live Activity found, restarting for existing session") await startLiveActivity(for: activeSession, gymName: gymName) } @@ -34,10 +62,17 @@ final class LiveActivityManager { 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( - gymName: gymName, startTime: session.startTime ?? session.date) + gymName: gymName, startTime: startTime) let initialContentState = SessionActivityAttributes.ContentState( - elapsed: 0, + elapsed: elapsed, totalAttempts: 0, completedProblems: 0 ) @@ -59,6 +94,8 @@ final class LiveActivityManager { print("Authorization error - check Live Activity permissions in Settings") } else if error.localizedDescription.contains("content") { 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 func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async { - guard let currentActivity else { + guard let currentActivity = currentActivity else { print("โš ๏ธ No current activity to update") return } + // Verify the activity is still valid before updating + let activities = Activity.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( "๐Ÿ”„ Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" ) @@ -81,12 +130,21 @@ final class LiveActivityManager { completedProblems: completedProblems ) - await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("โœ… Live Activity updated successfully") + do { + await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) + print("โœ… Live Activity updated successfully") + } catch { + print("โŒ Failed to update Live Activity: \(error)") + // If update fails, the activity might have been dismissed + self.currentActivity = nil + } } /// Call this when a ClimbSession ends to end the Live Activity func endLiveActivity() async { + // Stop health checks first + stopHealthChecks() + // First end the tracked activity if it exists if let currentActivity { print("๐Ÿ”ด Ending tracked Live Activity: \(currentActivity.id)") @@ -115,18 +173,92 @@ final class LiveActivityManager { func checkLiveActivityAvailability() -> String { let authorizationInfo = ActivityAuthorizationInfo() let status = authorizationInfo.areActivitiesEnabled + let allActivities = Activity.activities let message = """ Live Activity Status: โ€ข Enabled: \(status) โ€ข 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) return message } + /// Force check and cleanup dismissed Live Activities + func cleanupDismissedActivities() async { + let activities = Activity.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.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.activities + let trackedStatus = currentActivity != nil ? "Tracked" : "None" + let actualCount = activities.count + + return "Status: \(trackedStatus) | Active Count: \(actualCount)" + } + /// Start periodic updates for Live Activity func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) { diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 828182a..4d4874b 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -105,9 +105,26 @@ struct ProgressChartSection: View { @EnvironmentObject var dataManager: ClimbingDataManager @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 gradeCountData: [GradeCount] { - calculateGradeCounts() + 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] { diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index 192be69..b4ad000 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -15,6 +15,18 @@ struct SessionDetailView: View { 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? { guard let session = session else { return nil } return dataManager.gym(withId: session.gymId) @@ -35,7 +47,7 @@ struct SessionDetailView: View { calculateSessionStats() } - private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State private var timer: Timer? var body: some View { ScrollView { @@ -57,8 +69,11 @@ struct SessionDetailView: View { } .padding() } - .onReceive(timer) { _ in - currentTime = Date() + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() } .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) @@ -153,46 +168,14 @@ struct SessionDetailView: View { let uniqueProblems = Set(attempts.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( totalAttempts: attempts.count, successfulAttempts: successfulAttempts.count, uniqueProblemsAttempted: uniqueProblems.count, - uniqueProblemsCompleted: completedProblems.count, - boulderRange: boulderRange, - ropeRange: ropeRange + uniqueProblemsCompleted: completedProblems.count ) } - 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 { @@ -300,19 +283,6 @@ struct SessionStatsCard: View { StatItem(label: "Successful", value: "\(stats.successfulAttempts)") 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() @@ -504,8 +474,6 @@ struct SessionStats { let successfulAttempts: Int let uniqueProblemsAttempted: Int let uniqueProblemsCompleted: Int - let boulderRange: String? - let ropeRange: String? } #Preview { diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index 7014935..36b3fd2 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct ProblemsView: View { @@ -286,7 +285,7 @@ struct ProblemRow: View { if !problem.imagePaths.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { + LazyHStack(spacing: 8) { ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in ProblemImageView(imagePath: imagePath) } @@ -372,6 +371,13 @@ struct ProblemImageView: View { @State private var isLoading = true @State private var hasFailed = false + private static var imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 + cache.totalCostLimit = 50 * 1024 * 1024 // 50MB + return cache + }() + var body: some View { Group { if let uiImage = uiImage { @@ -412,10 +418,22 @@ struct ProblemImageView: View { 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 { if let data = ImageManager.shared.loadImageData(fromPath: imagePath), let image = UIImage(data: data) { + // Cache the image + Self.imageCache.setObject(image, forKey: cacheKey) + DispatchQueue.main.async { self.uiImage = image self.isLoading = false diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index dbb9f5f..a9df99b 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -114,7 +114,7 @@ struct ActiveSessionBanner: View { @State private var currentTime = Date() @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 { HStack { @@ -162,8 +162,11 @@ struct ActiveSessionBanner: View { .fill(.green.opacity(0.1)) .stroke(.green.opacity(0.3), lineWidth: 1) ) - .onReceive(timer) { _ in - currentTime = Date() + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() } .background( NavigationLink( @@ -190,6 +193,17 @@ struct ActiveSessionBanner: View { 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 { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index be5a7ff..79a832e 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -164,60 +164,70 @@ struct ExportDataView: View { let data: Data @Environment(\.dismiss) private var dismiss @State private var tempFileURL: URL? + @State private var isCreatingFile = true var body: some View { NavigationView { - VStack(spacing: 20) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 60)) - .foregroundColor(.blue) + VStack(spacing: 30) { + if isCreatingFile { + // Loading state - more prominent + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + .tint(.blue) - Text("Export Data") - .font(.title) - .fontWeight(.bold) + Text("Preparing Your Export") + .font(.title2) + .fontWeight(.semibold) - Text( - "Your climbing data has been prepared for export. Use the share button below to save or send your data." - ) - .multilineTextAlignment(.center) - .padding(.horizontal) - - if let fileURL = tempFileURL { - ShareLink( - item: fileURL, - preview: SharePreview( - "OpenClimb Data Export", - image: Image("MountainsIcon")) - ) { - Label("Share Data", systemImage: "square.and.arrow.up") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(.blue) - ) + Text("Creating ZIP file with your climbing data and images...") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - .padding(.horizontal) - .buttonStyle(.plain) + .frame(maxWidth: .infinity, maxHeight: .infinity) } 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) - } + // Ready state + VStack(spacing: 20) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.green) - Spacer() + Text("Export Ready!") + .font(.title) + .fontWeight(.bold) + + Text( + "Your climbing data has been prepared for export. Use the share button below to save or send your data." + ) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if let fileURL = tempFileURL { + ShareLink( + item: fileURL, + preview: SharePreview( + "OpenClimb Data Export", + image: Image("MountainsIcon")) + ) { + Label("Share Data", systemImage: "square.and.arrow.up") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.blue) + ) + } + .padding(.horizontal) + .buttonStyle(.plain) + } + } + + Spacer() + } } .padding() .navigationTitle("Export") @@ -259,6 +269,9 @@ struct ExportDataView: View { ).first else { print("Could not access Documents directory") + DispatchQueue.main.async { + self.isCreatingFile = false + } return } let fileURL = documentsURL.appendingPathComponent(filename) @@ -268,9 +281,13 @@ struct ExportDataView: View { DispatchQueue.main.async { self.tempFileURL = fileURL + self.isCreatingFile = false } } catch { print("Failed to create export file: \(error)") + DispatchQueue.main.async { + self.isCreatingFile = false + } } } }