diff --git a/.gitignore b/.gitignore index b897807..f124454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Gradle files .gradle/ build/ +release/ # Local configuration file (sdk path, etc) local.properties diff --git a/README.md b/README.md index 8e2ed22..2ff115a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This is a FOSS Android app meant to help climbers track their sessions, routes/p You have two options: 1. Download the latest APK from the Released page -2. Use Obtainium +2. Use Obtainium ## Requirements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94b8699..24db5fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 35 - versionCode = 6 - versionName = "0.3.3" + versionCode = 7 + versionName = "0.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt index eddc028..835e600 100644 --- a/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt +++ b/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt @@ -68,4 +68,41 @@ data class DifficultyGrade( val system: DifficultySystem, val grade: String, val numericValue: Int -) +) { + /** + * Compare this grade with another grade of the same system + * Returns negative if this grade is easier, positive if harder, 0 if equal + */ + fun compareTo(other: DifficultyGrade): Int { + if (system != other.system) return 0 + + return when (system) { + DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade) + DifficultySystem.FONT -> compareFontGrades(grade, other.grade) + DifficultySystem.YDS -> compareYDSGrades(grade, other.grade) + DifficultySystem.CUSTOM -> grade.compareTo(other.grade) + } + } + + private fun compareVScaleGrades(grade1: String, grade2: String): Int { + // Handle VB (easiest) specially + if (grade1 == "VB" && grade2 != "VB") return -1 + if (grade2 == "VB" && grade1 != "VB") return 1 + if (grade1 == "VB" && grade2 == "VB") return 0 + + // Extract numeric values for V grades + val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 + val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 + return num1.compareTo(num2) + } + + private fun compareFontGrades(grade1: String, grade2: String): Int { + // Simple string comparison for Font grades + return grade1.compareTo(grade2) + } + + private fun compareYDSGrades(grade1: String, grade2: String): Int { + // Simple string comparison for YDS grades + return grade1.compareTo(grade2) + } +} diff --git a/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt b/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt index 6146481..17b9fe3 100644 --- a/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt +++ b/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt @@ -89,9 +89,13 @@ class SessionTrackingService : Service() { private fun startSessionTracking(sessionId: String) { notificationJob?.cancel() notificationJob = serviceScope.launch { + // Initial notification update + updateNotification(sessionId) + + // Then update every second while (isActive) { + delay(1000L) updateNotification(sessionId) - delay(1000) } } } @@ -117,14 +121,15 @@ class SessionTrackingService : Service() { try { val start = LocalDateTime.parse(startTime) val now = LocalDateTime.now() - val minutes = ChronoUnit.MINUTES.between(start, now) - val hours = minutes / 60 - val remainingMinutes = minutes % 60 + val totalSeconds = ChronoUnit.SECONDS.between(start, now) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 when { - hours > 0 -> "${hours}h ${remainingMinutes}m" - remainingMinutes > 0 -> "${remainingMinutes}m" - else -> "< 1m" + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${totalSeconds}s" } } catch (_: Exception) { "Active" @@ -150,6 +155,10 @@ class SessionTrackingService : Service() { ) .build() + // Force update the notification every second + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + startForeground(NOTIFICATION_ID, notification) } catch (_: Exception) { // Handle errors gracefully diff --git a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt index 5b576ed..4fc9fe7 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -151,10 +151,7 @@ fun OpenClimbApp() { SessionDetailScreen( sessionId = args.sessionId, viewModel = viewModel, - onNavigateBack = { navController.popBackStack() }, - onNavigateToEdit = { sessionId -> - navController.navigate(Screen.AddEditSession(sessionId = sessionId)) - } + onNavigateBack = { navController.popBackStack() } ) } @@ -208,6 +205,7 @@ fun OpenClimbApp() { composable { backStackEntry -> val args = backStackEntry.toRoute() + LaunchedEffect(Unit) { fabConfig = null } AddEditSessionScreen( sessionId = args.sessionId, gymId = args.gymId, diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt b/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt index 47f0bed..f63cf72 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/components/ActiveSessionBanner.kt @@ -15,6 +15,7 @@ import com.atridad.openclimb.data.model.ClimbSession import com.atridad.openclimb.data.model.Gym import java.time.LocalDateTime import java.time.temporal.ChronoUnit +import kotlinx.coroutines.delay @Composable fun ActiveSessionBanner( @@ -24,6 +25,16 @@ fun ActiveSessionBanner( onEndSession: () -> Unit ) { if (activeSession != null) { + // Add a timer that updates every second for real-time duration counting + var currentTime by remember { mutableStateOf(LocalDateTime.now()) } + + LaunchedEffect(Unit) { + while (true) { + delay(1000) // Update every second + currentTime = LocalDateTime.now() + } + } + Card( modifier = Modifier .fillMaxWidth() @@ -67,7 +78,7 @@ fun ActiveSessionBanner( ) activeSession.startTime?.let { startTime -> - val duration = calculateDuration(startTime) + val duration = calculateDuration(startTime, currentTime) Text( text = duration, style = MaterialTheme.typography.bodySmall, @@ -93,18 +104,18 @@ fun ActiveSessionBanner( } } -private fun calculateDuration(startTimeString: String): String { +private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String { return try { val startTime = LocalDateTime.parse(startTimeString) - val now = LocalDateTime.now() - val minutes = ChronoUnit.MINUTES.between(startTime, now) - val hours = minutes / 60 - val remainingMinutes = minutes % 60 + val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 when { - hours > 0 -> "${hours}h ${remainingMinutes}m" - remainingMinutes > 0 -> "${remainingMinutes}m" - else -> "< 1m" + hours > 0 -> "${hours}h ${minutes}m ${seconds}s" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${totalSeconds}s" } } catch (_: Exception) { "Active" diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt index 9739d25..3bbfa92 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt @@ -5,11 +5,9 @@ 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.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -18,21 +16,12 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import com.atridad.openclimb.data.model.* import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import kotlinx.coroutines.flow.first import java.time.LocalDateTime -// Data class for attempt input -data class AttemptInput( - val problemId: String, - val result: AttemptResult, - val highestHold: String = "", - val notes: String = "" -) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddEditGymScreen( @@ -278,7 +267,6 @@ fun AddEditProblemScreen( notes = p.notes ?: "" isActive = p.isActive imagePaths = p.imagePaths - // Set the selected gym for the existing problem selectedGym = gyms.find { it.id == p.gymId } } } @@ -700,18 +688,13 @@ fun AddEditSessionScreen( ) { val isEditing = sessionId != null val gyms by viewModel.gyms.collectAsState() - val problems by viewModel.problems.collectAsState() - + // Session form state var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) } var duration by remember { mutableStateOf("") } var sessionNotes by remember { mutableStateOf("") } - // Attempt tracking state - var attempts by remember { mutableStateOf(listOf()) } - var showAddAttemptDialog by remember { mutableStateOf(false) } - // Load existing session data for editing LaunchedEffect(sessionId) { if (sessionId != null) { @@ -753,17 +736,6 @@ fun AddEditSessionScreen( viewModel.updateSession(session.copy(id = sessionId)) } else { viewModel.addSession(session) - - attempts.forEach { attemptInput -> - val attempt = Attempt.create( - sessionId = session.id, - problemId = attemptInput.problemId, - result = attemptInput.result, - highestHold = attemptInput.highestHold.ifBlank { null }, - notes = attemptInput.notes.ifBlank { null } - ) - viewModel.addAttempt(attempt) - } } onNavigateBack() } @@ -774,15 +746,6 @@ fun AddEditSessionScreen( } } ) - }, - floatingActionButton = { - if (selectedGym != null) { - FloatingActionButton( - onClick = { showAddAttemptDialog = true } - ) { - Icon(Icons.Default.Add, contentDescription = "Add Attempt") - } - } } ) { paddingValues -> LazyColumn( @@ -878,285 +841,9 @@ fun AddEditSessionScreen( } } } - - // Attempts Section - item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Attempts (${attempts.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - - } - - if (attempts.isEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "No attempts recorded yet. Add an attempt to track your progress.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - // Attempts List - items(attempts.size) { index -> - val attempt = attempts[index] - val problem = problems.find { it.id == attempt.problemId } - - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = problem?.name ?: "Unknown Problem", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - - problem?.difficulty?.let { difficulty -> - Text( - text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - - Text( - text = "Result: ${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }}", - style = MaterialTheme.typography.bodyMedium, - color = when (attempt.result) { - AttemptResult.SUCCESS, AttemptResult.FLASH -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - ) - - if (attempt.highestHold.isNotBlank()) { - Text( - text = "Highest hold: ${attempt.highestHold}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (attempt.notes.isNotBlank()) { - Text( - text = attempt.notes, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - IconButton( - onClick = { - attempts = attempts.toMutableList().apply { removeAt(index) } - } - ) { - Icon(Icons.Default.Delete, contentDescription = "Remove attempt") - } - } - } - } - } - } - } - - if (showAddAttemptDialog && selectedGym != null) { - AddAttemptDialog( - problems = problems.filter { it.gymId == selectedGym!!.id && it.isActive }, - onDismiss = { showAddAttemptDialog = false }, - onAddAttempt = { attemptInput -> - attempts = attempts + attemptInput - showAddAttemptDialog = false - } - ) - } -} - - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddAttemptDialog( - problems: List, - onDismiss: () -> Unit, - onAddAttempt: (AttemptInput) -> Unit -) { - var selectedProblem by remember { mutableStateOf(null) } - var selectedResult by remember { mutableStateOf(AttemptResult.FALL) } - var highestHold by remember { mutableStateOf("") } - var notes by remember { mutableStateOf("") } - - Dialog(onDismissRequest = onDismiss) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Add Attempt", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - // Problem Selection - Text( - text = "Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - if (problems.isEmpty()) { - Text( - text = "No active problems in this gym. Add some problems first.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) - } else { - LazyColumn( - modifier = Modifier.height(120.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(problems) { problem -> - Card( - onClick = { selectedProblem = problem }, - colors = CardDefaults.cardColors( - containerColor = if (selectedProblem?.id == problem.id) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surface - ), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(12.dp) - ) { - Text( - text = problem.name ?: "Unnamed Problem", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } - } - - // Result Selection - Text( - text = "Result", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - - Column(modifier = Modifier.selectableGroup()) { - AttemptResult.entries.forEach { result -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selectedResult == result, - onClick = { selectedResult = result }, - role = Role.RadioButton - ) - ) { - RadioButton( - selected = selectedResult == result, - onClick = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = result.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - // Highest Hold - OutlinedTextField( - value = highestHold, - onValueChange = { highestHold = it }, - label = { Text("Highest Hold (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("e.g., 'jugs near the top', 'crux move'") } - ) - - // Notes - OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - label = { Text("Notes (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") } - ) - - // Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextButton( - onClick = onDismiss, - modifier = Modifier.weight(1f) - ) { - Text("Cancel") - } - - Button( - onClick = { - selectedProblem?.let { problem -> - onAddAttempt( - AttemptInput( - problemId = problem.id, - result = selectedResult, - highestHold = highestHold, - notes = notes - ) - ) - } - }, - enabled = selectedProblem != null, - modifier = Modifier.weight(1f) - ) { - Text("Add Attempt") - } - } - } } } } + + + diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt index b953ae4..c9de849 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt @@ -93,41 +93,6 @@ fun AnalyticsScreen( val recentSessions = sessions.take(5) RecentActivityCard(recentSessions = recentSessions.size) } - - - item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Progress Charts", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Detailed charts and analytics coming soon!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "📊", - style = MaterialTheme.typography.displaySmall - ) - } - } - } } } diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt index a2ef10c..5a4dfd1 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt @@ -1,6 +1,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -14,70 +15,234 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight - import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewModelScope +import com.atridad.openclimb.data.model.* import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.openclimb.ui.components.ImageDisplaySection import com.atridad.openclimb.ui.viewmodel.ClimbViewModel -import com.atridad.openclimb.data.model.* -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import androidx.lifecycle.viewModelScope import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SessionDetailScreen( - sessionId: String, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit, - onNavigateToEdit: (String) -> Unit +fun EditAttemptDialog( + 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) } + var highestHold by remember { mutableStateOf(attempt.highestHold ?: "") } + var notes by remember { mutableStateOf(attempt.notes ?: "") } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = "Edit Attempt", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + // Problem Selection + Text( + text = "Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + if (problems.isEmpty()) { + Text( + text = "No 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 } + ) + + DropdownMenu( + 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 + } + ) + } + } + } + } + + // Result Selection + Text( + text = "Result", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + + Column( + 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 + ) { + 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 + ) + } + } + } + + // 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 + ) + + // Notes + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + placeholder = { Text("e.g., 'need to work on heel hooks', 'pumped out'") } + ) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text("Cancel") + } + + Button( + onClick = { + selectedProblem?.let { problem -> + 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") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionDetailScreen(sessionId: String, viewModel: ClimbViewModel, onNavigateBack: () -> Unit) { val context = LocalContext.current val attempts by viewModel.getAttemptsBySession(sessionId).collectAsState(initial = emptyList()) val sessions by viewModel.sessions.collectAsState() val problems by viewModel.problems.collectAsState() val gyms by viewModel.gyms.collectAsState() - + var isGeneratingShare by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } var showAddAttemptDialog by remember { mutableStateOf(false) } - + var showEditAttemptDialog by remember { mutableStateOf(null) } + // Get session details val session = sessions.find { it.id == sessionId } val gym = session?.let { s -> gyms.find { it.id == s.gymId } } - + // Calculate stats - val successfulAttempts = attempts.filter { - it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) - } + val successfulAttempts = + 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 - }.sortedByDescending { attempt -> - // Sort by result priority, then by timestamp - when (attempt.first.result) { - AttemptResult.FLASH -> 3 - AttemptResult.SUCCESS -> 2 - AttemptResult.FALL -> 1 - else -> 0 - } - } - + + 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 + } + Scaffold( topBar = { TopAppBar( @@ -94,7 +259,8 @@ fun SessionDetailScreen( onClick = { isGeneratingShare = true viewModel.viewModelScope.launch { - val shareFile = viewModel.generateSessionShareCard(context, sessionId) + val shareFile = + viewModel.generateSessionShareCard(context, sessionId) isGeneratingShare = false shareFile?.let { file -> viewModel.shareSessionCard(context, file) @@ -116,62 +282,49 @@ fun SessionDetailScreen( } } } - + IconButton(onClick = { showDeleteDialog = true }) { Icon(Icons.Default.Delete, contentDescription = "Delete") } - - IconButton(onClick = { onNavigateToEdit(sessionId) }) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } } ) }, floatingActionButton = { if (session?.status == SessionStatus.ACTIVE) { - FloatingActionButton( - onClick = { showAddAttemptDialog = true } - ) { + FloatingActionButton(onClick = { showAddAttemptDialog = true }) { Icon(Icons.Default.Add, contentDescription = "Add Attempt") } } } ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(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) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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 ) - + session?.let { s -> if (s.duration != null) { Spacer(modifier = Modifier.height(8.dp)) - + val timeText = "Duration: ${s.duration} minutes" - + Text( text = timeText, style = MaterialTheme.typography.bodyMedium, @@ -179,56 +332,50 @@ fun SessionDetailScreen( ) } } - + session?.notes?.let { notes -> Spacer(modifier = Modifier.height(12.dp)) - Text( - text = notes, - style = MaterialTheme.typography.bodyMedium - ) + Text(text = notes, style = MaterialTheme.typography.bodyMedium) } - + // Session status indicator Spacer(modifier = Modifier.height(12.dp)) - + Surface( - color = if (session?.duration != null) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.secondaryContainer, + 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", + 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, + color = + if (session?.duration != null) + MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Medium ) } } } } - + // Stats Summary item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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", @@ -240,22 +387,16 @@ fun SessionDetailScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - StatItem( - label = "Total Attempts", - value = attempts.size.toString() - ) - StatItem( - label = "Problems", - value = uniqueProblems.size.toString() - ) + StatItem(label = "Total Attempts", value = attempts.size.toString()) + StatItem(label = "Problems", value = uniqueProblems.size.toString()) StatItem( label = "Successful", value = successfulAttempts.size.toString() ) } - + Spacer(modifier = Modifier.height(16.dp)) - + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly @@ -269,25 +410,84 @@ fun SessionDetailScreen( value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%" ) - // Show grade range if available - val grades = attemptedProblems.map { it.difficulty.grade } - if (grades.isNotEmpty()) { + // Show average grade if available + val attemptedProblems = problems.filter { it.id in uniqueProblems } + if (attemptedProblems.isNotEmpty()) { + val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } + val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } + + val averageGrade = when { + boulderProblems.isNotEmpty() && ropeProblems.isNotEmpty() -> { + val boulderAvg = calculateAverageGrade(boulderProblems) + val ropeAvg = calculateAverageGrade(ropeProblems) + "${boulderAvg ?: "N/A"} / ${ropeAvg ?: "N/A"}" + } + boulderProblems.isNotEmpty() -> calculateAverageGrade(boulderProblems) ?: "N/A" + ropeProblems.isNotEmpty() -> calculateAverageGrade(ropeProblems) ?: "N/A" + else -> "N/A" + } + StatItem( - label = "Grade Range", - value = "${grades.minOrNull()} - ${grades.maxOrNull()}" + label = "Average Grade", + value = averageGrade ) } else { StatItem( - label = "Grade Range", + label = "Average Grade", value = "N/A" ) } } + + // Show grade range if available + val grades = attemptedProblems.map { it.difficulty } + if (grades.isNotEmpty()) { + // Separate boulder and rope problems + val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } + val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } + + val gradeRange = when { + boulderProblems.isNotEmpty() && ropeProblems.isNotEmpty() -> { + val boulderRange = if (boulderProblems.isNotEmpty()) { + val boulderGrades = boulderProblems.map { it.difficulty } + val sortedBoulderGrades = boulderGrades.sortedWith { a, b -> a.compareTo(b) } + "${sortedBoulderGrades.first().grade} - ${sortedBoulderGrades.last().grade}" + } else null + + val ropeRange = if (ropeProblems.isNotEmpty()) { + val ropeGrades = ropeProblems.map { it.difficulty } + val sortedRopeGrades = ropeGrades.sortedWith { a, b -> a.compareTo(b) } + "${sortedRopeGrades.first().grade} - ${sortedRopeGrades.last().grade}" + } else null + + when { + boulderRange != null && ropeRange != null -> "$boulderRange / $ropeRange" + boulderRange != null -> boulderRange + ropeRange != null -> ropeRange + else -> "N/A" + } + } + else -> { + val sortedGrades = grades.sortedWith { a, b -> a.compareTo(b) } + "${sortedGrades.first().grade} - ${sortedGrades.last().grade}" + } + } + + StatItem( + label = "Grade Range", + value = gradeRange + ) + } else { + StatItem( + label = "Grade Range", + value = "N/A" + ) + } } } } } - + // Attempts List item { Text( @@ -296,16 +496,12 @@ fun SessionDetailScreen( fontWeight = FontWeight.Bold ) } - + if (attemptsWithProblems.isEmpty()) { item { - Card( - modifier = Modifier.fillMaxWidth() - ) { + Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( @@ -327,13 +523,17 @@ fun SessionDetailScreen( val (attempt, problem) = attemptsWithProblems[index] SessionAttemptCard( attempt = attempt, - problem = problem + problem = problem, + onEditAttempt = { attemptToEdit -> showEditAttemptDialog = attemptToEdit }, + onDeleteAttempt = { attemptToDelete -> + viewModel.deleteAttempt(attemptToDelete) + } ) } } } } - + // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( @@ -350,43 +550,52 @@ fun SessionDetailScreen( ) } }, - 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") + 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") } } - - 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) - } - ) + ) + } + + 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) } + ) + } + + // Edit attempt dialog + showEditAttemptDialog?.let { attempt -> + EditAttemptDialog( + attempt = attempt, + problems = problems.filter { it.isActive }, + onDismiss = { showEditAttemptDialog = null }, + onAttemptUpdated = { updatedAttempt -> + viewModel.updateAttempt(updatedAttempt) + showEditAttemptDialog = null } - } + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -403,29 +612,30 @@ fun ProblemDetailScreen( val attempts by viewModel.getAttemptsByProblem(problemId).collectAsState(initial = emptyList()) val sessions by viewModel.sessions.collectAsState() val gyms by viewModel.gyms.collectAsState() - + // Get problem details var problem by remember { mutableStateOf(null) } - - LaunchedEffect(problemId) { - problem = viewModel.getProblemById(problemId).first() - } - + + LaunchedEffect(problemId) { problem = viewModel.getProblemById(problemId).first() } + val gym = problem?.let { p -> gyms.find { it.id == p.gymId } } - + // Calculate stats - val successfulAttempts = attempts.filter { - it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) - } - val successRate = if (attempts.isNotEmpty()) { - (successfulAttempts.size.toDouble() / attempts.size * 100).toInt() - } else 0 - - val attemptsWithSessions = attempts.mapNotNull { attempt -> - val session = sessions.find { it.id == attempt.sessionId } - if (session != null) attempt to session else null - }.sortedByDescending { it.second.date } - + val successfulAttempts = + attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } + val successRate = + if (attempts.isNotEmpty()) { + (successfulAttempts.size.toDouble() / attempts.size * 100).toInt() + } else 0 + + val attemptsWithSessions = + 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( @@ -447,28 +657,21 @@ fun ProblemDetailScreen( } ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(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) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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, @@ -477,13 +680,14 @@ fun ProblemDetailScreen( Column { problem?.let { p -> Text( - text = "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", + 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(), @@ -492,7 +696,7 @@ fun ProblemDetailScreen( ) } } - + gym?.let { g -> Column(horizontalAlignment = Alignment.End) { Text( @@ -500,7 +704,7 @@ fun ProblemDetailScreen( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium ) - + problem?.location?.let { location -> Text( text = location, @@ -511,15 +715,12 @@ fun ProblemDetailScreen( } } } - + problem?.description?.let { description -> Spacer(modifier = Modifier.height(12.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium - ) + Text(text = description, style = MaterialTheme.typography.bodyMedium) } - + // Display images if any problem?.let { p -> if (p.imagePaths.isNotEmpty()) { @@ -534,7 +735,7 @@ fun ProblemDetailScreen( ) } } - + problem?.setter?.let { setter -> Spacer(modifier = Modifier.height(8.dp)) Text( @@ -543,21 +744,16 @@ fun ProblemDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + if (problem?.tags?.isNotEmpty() == true) { Spacer(modifier = Modifier.height(12.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(problem?.tags ?: emptyList()) { tag -> - AssistChip( - onClick = { }, - label = { Text(tag) } - ) + AssistChip(onClick = {}, label = { Text(tag) }) } } } - + problem?.notes?.let { notes -> Spacer(modifier = Modifier.height(12.dp)) Text( @@ -569,23 +765,19 @@ fun ProblemDetailScreen( } } } - + // Progress Summary item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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", @@ -597,30 +789,26 @@ fun ProblemDetailScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - StatItem( - label = "Total Attempts", - value = attempts.size.toString() - ) + StatItem(label = "Total Attempts", value = attempts.size.toString()) StatItem( label = "Successful", value = successfulAttempts.size.toString() ) - StatItem( - label = "Success Rate", - value = "$successRate%" - ) + StatItem(label = "Success Rate", value = "$successRate%") } - + Spacer(modifier = Modifier.height(12.dp)) - + if (successfulAttempts.isNotEmpty()) { - val firstSuccess = successfulAttempts.minByOrNull { attempt -> - sessions.find { it.id == attempt.sessionId }?.date ?: "" - } + val firstSuccess = + 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() }})", + text = + "First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) @@ -630,7 +818,7 @@ fun ProblemDetailScreen( } } } - + // Attempt History item { Text( @@ -639,16 +827,12 @@ fun ProblemDetailScreen( fontWeight = FontWeight.Bold ) } - + if (attemptsWithSessions.isEmpty()) { item { - Card( - modifier = Modifier.fillMaxWidth() - ) { + Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( @@ -668,16 +852,12 @@ fun ProblemDetailScreen( } else { items(attemptsWithSessions.size) { index -> val (attempt, session) = attemptsWithSessions[index] - AttemptHistoryCard( - attempt = attempt, - session = session, - gym = gym - ) + AttemptHistoryCard(attempt = attempt, session = session, gym = gym) } } } } - + // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( @@ -708,13 +888,11 @@ fun ProblemDetailScreen( } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("Cancel") - } + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } ) } - + // Fullscreen Image Viewer problem?.let { p -> if (showImageViewer && p.imagePaths.isNotEmpty()) { @@ -740,26 +918,27 @@ fun GymDetailScreen( val problems by viewModel.getProblemsByGym(gymId).collectAsState(initial = emptyList()) val sessions by viewModel.getSessionsByGym(gymId).collectAsState(initial = emptyList()) val allAttempts by viewModel.attempts.collectAsState() - + // Calculate statistics - val gymAttempts = allAttempts.filter { attempt -> - problems.any { problem -> problem.id == attempt.problemId } - } - - val successfulAttempts = gymAttempts.filter { - it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) - } - - val successRate = if (gymAttempts.isNotEmpty()) { - (successfulAttempts.size.toDouble() / gymAttempts.size * 100).toInt() - } else 0 - + val gymAttempts = + allAttempts.filter { attempt -> + problems.any { problem -> problem.id == attempt.problemId } + } + + val successfulAttempts = + gymAttempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } + + val successRate = + if (gymAttempts.isNotEmpty()) { + (successfulAttempts.size.toDouble() / gymAttempts.size * 100).toInt() + } else 0 + val uniqueProblemsClimbed = gymAttempts.map { it.problemId }.toSet().size val totalSessions = sessions.size val activeSessions = sessions.count { it.status == SessionStatus.ACTIVE } - + var showDeleteDialog by remember { mutableStateOf(false) } - + Scaffold( topBar = { TopAppBar( @@ -782,36 +961,26 @@ fun GymDetailScreen( ) { paddingValues -> if (gym == null) { Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), + modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center ) { Text("Gym not found") } } else { LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(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) - ) { + 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 ) - + if (gym.location?.isNotBlank() == true) { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -820,43 +989,33 @@ fun GymDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + if (gym.notes?.isNotBlank() == true) { Spacer(modifier = Modifier.height(8.dp)) - Text( - text = gym.notes, - style = MaterialTheme.typography.bodyMedium - ) + Text(text = gym.notes, style = MaterialTheme.typography.bodyMedium) } } } } - + // Statistics Card item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(20.dp)) { Text( text = "Statistics", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(16.dp)) - + // Statistics Grid Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = problems.size.toString(), style = MaterialTheme.typography.headlineSmall, @@ -869,9 +1028,7 @@ fun GymDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = totalSessions.toString(), style = MaterialTheme.typography.headlineSmall, @@ -884,9 +1041,7 @@ fun GymDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = "$successRate%", style = MaterialTheme.typography.headlineSmall, @@ -900,16 +1055,14 @@ fun GymDetailScreen( ) } } - + Spacer(modifier = Modifier.height(12.dp)) - + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = gymAttempts.size.toString(), style = MaterialTheme.typography.headlineSmall, @@ -922,9 +1075,7 @@ fun GymDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = uniqueProblemsClimbed.toString(), style = MaterialTheme.typography.headlineSmall, @@ -938,9 +1089,7 @@ fun GymDetailScreen( ) } if (activeSessions > 0) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = activeSessions.toString(), style = MaterialTheme.typography.headlineSmall, @@ -958,7 +1107,7 @@ fun GymDetailScreen( } } } - + // Recent Problems Card if (problems.isNotEmpty()) { item { @@ -966,56 +1115,67 @@ fun GymDetailScreen( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp) ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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 = gymAttempts.filter { it.problemId == problem.id } - val problemSuccessful = problemAttempts.any { - it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(12.dp) - ) { - ListItem( - headlineContent = { - Text( - text = problem.name ?: "Unnamed Problem", - fontWeight = FontWeight.Medium - ) - }, - supportingContent = { - Text("${problem.difficulty.grade} • ${problem.climbType} • ${problemAttempts.size} attempts") - }, - trailingContent = { - if (problemSuccessful) { - Icon( - Icons.Default.Check, - contentDescription = "Completed", - tint = MaterialTheme.colorScheme.primary + problems + .sortedByDescending { it.createdAt } + .take(5) + .forEach { problem -> + val problemAttempts = + gymAttempts.filter { it.problemId == problem.id } + val problemSuccessful = + problemAttempts.any { + it.result in + listOf( + AttemptResult.SUCCESS, + AttemptResult.FLASH ) - } } - ) + + Card( + modifier = + Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + ListItem( + headlineContent = { + Text( + text = problem.name ?: "Unnamed Problem", + fontWeight = FontWeight.Medium + ) + }, + supportingContent = { + Text( + "${problem.difficulty.grade} • ${problem.climbType} • ${problemAttempts.size} attempts" + ) + }, + trailingContent = { + if (problemSuccessful) { + Icon( + Icons.Default.Check, + contentDescription = "Completed", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + } } - } - + if (problems.size > 5) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -1028,7 +1188,7 @@ fun GymDetailScreen( } } } - + // Recent Sessions Card if (sessions.isNotEmpty()) { item { @@ -1036,75 +1196,104 @@ fun GymDetailScreen( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp) ) { - Column( - modifier = Modifier.padding(20.dp) - ) { + Column(modifier = Modifier.padding(20.dp)) { Text( 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 = gymAttempts.filter { it.sessionId == session.id } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(12.dp) - ) { - ListItem( - headlineContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (session.status == SessionStatus.ACTIVE) "Active Session" - else "Session", - fontWeight = FontWeight.Medium - ) - if (session.status == SessionStatus.ACTIVE) { - Badge( - containerColor = MaterialTheme.colorScheme.primary + sessions + .sortedByDescending { it.date } + .take(3) + .forEach { session -> + val sessionAttempts = + gymAttempts.filter { it.sessionId == session.id } + + Card( + modifier = + Modifier.fillMaxWidth().padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + ListItem( + headlineContent = { + Row( + horizontalArrangement = + Arrangement.spacedBy(8.dp), + verticalAlignment = + Alignment.CenterVertically + ) { + Text( + text = + if ( + session.status == + SessionStatus.ACTIVE + ) + "Active Session" + else "Session", + fontWeight = FontWeight.Medium + ) + if ( + session.status == SessionStatus.ACTIVE ) { - Text("ACTIVE", style = MaterialTheme.typography.labelSmall) + Badge( + containerColor = + MaterialTheme.colorScheme + .primary + ) { + Text( + "ACTIVE", + style = + MaterialTheme.typography + .labelSmall + ) + } } } - } - }, - supportingContent = { - val dateTime = try { - LocalDateTime.parse(session.date) - } catch (_: Exception) { - null - } - val formattedDate = dateTime?.format( - DateTimeFormatter.ofPattern("MMM dd, yyyy") - ) ?: session.date - - Text("$formattedDate • ${sessionAttempts.size} attempts") - }, - trailingContent = { - session.duration?.let { duration -> + }, + supportingContent = { + val dateTime = + try { + LocalDateTime.parse(session.date) + } catch (_: Exception) { + null + } + val formattedDate = + dateTime?.format( + DateTimeFormatter.ofPattern( + "MMM dd, yyyy" + ) + ) ?: session.date + Text( - text = "${duration}min", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + "$formattedDate • ${sessionAttempts.size} attempts" ) + }, + trailingContent = { + session.duration?.let { duration -> + Text( + text = "${duration}min", + style = + MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme + .onSurfaceVariant + ) + } } - } - ) + ) + } } - } - + if (sessions.size > 3) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -1117,7 +1306,7 @@ fun GymDetailScreen( } } } - + // Empty state if no data if (problems.isEmpty() && sessions.isEmpty()) { item { @@ -1126,9 +1315,7 @@ fun GymDetailScreen( shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), + modifier = Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( @@ -1149,7 +1336,7 @@ fun GymDetailScreen( } } } - + // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( @@ -1160,7 +1347,8 @@ fun GymDetailScreen( 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.", + text = + "This will also delete all problems and sessions associated with this gym.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -1180,24 +1368,15 @@ fun GymDetailScreen( } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("Cancel") - } + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } } ) } } - - @Composable -fun StatItem( - label: String, - value: String -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { +fun StatItem(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = value, style = MaterialTheme.typography.headlineSmall, @@ -1213,17 +1392,9 @@ fun StatItem( } @Composable -fun AttemptHistoryCard( - attempt: Attempt, - session: ClimbSession, - gym: Gym? -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { +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, @@ -1243,16 +1414,13 @@ fun AttemptHistoryCard( ) } } - + AttemptResultBadge(result = attempt.result) } - + attempt.notes?.let { notes -> Spacer(modifier = Modifier.height(8.dp)) - Text( - text = notes, - style = MaterialTheme.typography.bodyMedium - ) + Text(text = notes, style = MaterialTheme.typography.bodyMedium) } } } @@ -1260,24 +1428,28 @@ fun AttemptHistoryCard( @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 - } - - val textColor = 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) - ) { + val backgroundColor = + 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 + } + + Surface(color = backgroundColor, shape = RoundedCornerShape(12.dp)) { Text( - text = result.name.lowercase().replaceFirstChar { it.uppercase() }, + 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, @@ -1289,14 +1461,14 @@ fun AttemptResultBadge(result: AttemptResult) { @Composable fun SessionAttemptCard( attempt: Attempt, - problem: Problem + problem: Problem, + onEditAttempt: (Attempt) -> Unit = {}, + onDeleteAttempt: (Attempt) -> Unit = {} ) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + var showDeleteDialog by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1308,13 +1480,14 @@ fun SessionAttemptCard( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium ) - + Text( - text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", + text = + "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) - + problem.location?.let { location -> Text( text = location, @@ -1323,19 +1496,71 @@ fun SessionAttemptCard( ) } } - - AttemptResultBadge(result = attempt.result) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AttemptResultBadge(result = attempt.result) + + // Edit button + IconButton( + onClick = { onEditAttempt(attempt) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit attempt", + modifier = Modifier.size(16.dp) + ) + } + + // Delete button + IconButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(32.dp), + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete attempt", + modifier = Modifier.size(16.dp) + ) + } + } } - + attempt.notes?.let { notes -> Spacer(modifier = Modifier.height(8.dp)) - Text( - text = notes, - style = MaterialTheme.typography.bodyMedium - ) + Text(text = notes, style = MaterialTheme.typography.bodyMedium) } } } + + // 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) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } + } + ) + } } private fun formatDate(dateString: String): String { @@ -1349,6 +1574,76 @@ private fun formatDate(dateString: String): String { } } +/** + * Calculate average grade for a specific set of problems, respecting their difficulty systems + */ +private fun calculateAverageGrade(problems: List): String? { + if (problems.isEmpty()) return null + + // Group problems by difficulty system + val problemsBySystem = problems.groupBy { it.difficulty.system } + + val averages = mutableListOf() + + problemsBySystem.forEach { (system, systemProblems) -> + when (system) { + DifficultySystem.V_SCALE -> { + val gradeValues = systemProblems.mapNotNull { problem -> + when { + problem.difficulty.grade == "VB" -> 0 + else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() + } + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average().roundToInt() + averages.add(if (avg == 0) "VB" else "V$avg") + } + } + DifficultySystem.FONT -> { + val gradeValues = systemProblems.mapNotNull { problem -> + // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7) + problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average().roundToInt() + averages.add("$avg") + } + } + DifficultySystem.YDS -> { + val gradeValues = systemProblems.mapNotNull { problem -> + // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) + val grade = problem.difficulty.grade + if (grade.startsWith("5.")) { + grade.substring(2).toDoubleOrNull() + } else null + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average() + averages.add("5.${String.format("%.1f", avg)}") + } + } + DifficultySystem.CUSTOM -> { + // For custom systems, try to extract numeric values + val gradeValues = systemProblems.mapNotNull { problem -> + problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average() + averages.add(String.format("%.1f", avg)) + } + } + } + } + + return if (averages.isNotEmpty()) { + if (averages.size == 1) { + averages.first() + } else { + averages.joinToString(" / ") + } + } else null +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun EnhancedAddAttemptDialog( @@ -1364,30 +1659,39 @@ fun EnhancedAddAttemptDialog( var highestHold by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") } var showCreateProblem by remember { mutableStateOf(false) } - + // New problem creation state var newProblemName by remember { mutableStateOf("") } var newProblemGrade by remember { mutableStateOf("") } var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } - var selectedDifficultySystem by remember { mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE) } - + var selectedDifficultySystem by remember { + mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE) + } + // 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() } } - + // 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) - } - + val availableSystems = + 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 + selectedDifficultySystem = + availableSystems.firstOrNull() + ?: gym.difficultySystems.firstOrNull() + ?: DifficultySystem.CUSTOM } // If there's only one available system, auto-select it availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> { @@ -1395,7 +1699,7 @@ fun EnhancedAddAttemptDialog( } } } - + // Reset grade when difficulty system changes LaunchedEffect(selectedDifficultySystem) { val availableGrades = selectedDifficultySystem.getAvailableGrades() @@ -1403,21 +1707,19 @@ fun EnhancedAddAttemptDialog( newProblemGrade = "" } } - + Dialog(onDismissRequest = onDismiss) { Card( modifier = Modifier - .fillMaxWidth() + .fillMaxWidth(0.95f) .fillMaxHeight(0.9f) - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) + .padding(16.dp) ) { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { Text( text = "Add Attempt", @@ -1425,441 +1727,495 @@ fun EnhancedAddAttemptDialog( fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 20.dp) ) - + LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), + 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 - ) - - if (problems.isEmpty()) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - modifier = Modifier.fillMaxWidth() - ) { - Column( - 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 - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = { showCreateProblem = true } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + 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() + ) { + Column( + 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 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button(onClick = { showCreateProblem = true }) { + Text("Create New Problem") + } + } + } + } else { + LazyColumn( + 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 = + if (isSelected) + MaterialTheme.colorScheme + .primaryContainer + else + MaterialTheme.colorScheme + .surfaceVariant, + ), + border = + if (isSelected) + 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 + ) + 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 + ) + } + } + } + } + + // Option to create new problem + OutlinedButton( + onClick = { showCreateProblem = true }, + modifier = Modifier.fillMaxWidth() ) { Text("Create New Problem") } } } } else { - LazyColumn( - 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 = if (isSelected) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant, - ), - border = if (isSelected) - 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 - ) - 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 + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + 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 + ) + + TextButton(onClick = { showCreateProblem = false }) { + Text("← Back", color = MaterialTheme.colorScheme.primary) + } + } + + 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 + ) + ) + + // Climb Type Selection + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + 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 + ) ) } } } - } - - // Option to create new problem - OutlinedButton( - onClick = { showCreateProblem = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Create New Problem") + + // Difficulty System Selection + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + 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) + } + 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 + ) + ) + } + } + } + + if (selectedDifficultySystem == DifficultySystem.CUSTOM) { + OutlinedTextField( + value = newProblemGrade, + onValueChange = { newProblemGrade = it }, + label = { Text("Grade *") }, + placeholder = { Text("Enter custom grade") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + MaterialTheme.colorScheme.primary, + unfocusedBorderColor = + MaterialTheme.colorScheme.outline + ), + isError = newProblemGrade.isBlank(), + supportingText = + if (newProblemGrade.isBlank()) { + { + Text( + "Grade is required", + color = MaterialTheme.colorScheme.error + ) + } + } else null + ) + } else { + var expanded by remember { mutableStateOf(false) } + val availableGrades = + selectedDifficultySystem.getAvailableGrades() + + ExposedDropdownMenuBox( + 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().fillMaxWidth(), + isError = newProblemGrade.isBlank(), + supportingText = + if (newProblemGrade.isBlank()) { + { + Text( + "Grade is required", + color = MaterialTheme.colorScheme.error + ) + } + } else null + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availableGrades.forEach { grade -> + DropdownMenuItem( + text = { Text(grade) }, + onClick = { + newProblemGrade = grade + expanded = false + } + ) + } + } + } + } } } } - } else { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { + + // Result Selection (always shown) + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Create New Problem", + text = "Attempt Result", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) - - TextButton( - onClick = { showCreateProblem = false } - ) { - Text("← Back", color = MaterialTheme.colorScheme.primary) - } - } - - 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 - ) - ) - - // Climb Type Selection - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - 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 - ) + + Card( + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) ) - } - } - } - - // Difficulty System Selection - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - 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) - } - items(availableSystems) { system -> - FilterChip( - onClick = { selectedDifficultySystem = system }, - label = { + 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) + ) { + RadioButton( + selected = selectedResult == result, + onClick = null, + colors = + RadioButtonDefaults.colors( + selectedColor = + MaterialTheme.colorScheme.primary + ) + ) + Spacer(modifier = Modifier.width(12.dp)) Text( - system.getDisplayName(), - fontWeight = FontWeight.Medium - ) - }, - selected = selectedDifficultySystem == system, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - } - } - } - - if (selectedDifficultySystem == DifficultySystem.CUSTOM) { - OutlinedTextField( - value = newProblemGrade, - onValueChange = { newProblemGrade = it }, - label = { Text("Grade *") }, - placeholder = { Text("Enter custom grade") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline - ), - isError = newProblemGrade.isBlank(), - supportingText = if (newProblemGrade.isBlank()) { - { Text("Grade is required", color = MaterialTheme.colorScheme.error) } - } else null - ) - } else { - var expanded by remember { mutableStateOf(false) } - val availableGrades = selectedDifficultySystem.getAvailableGrades() - - ExposedDropdownMenuBox( - 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() - .fillMaxWidth(), - isError = newProblemGrade.isBlank(), - supportingText = if (newProblemGrade.isBlank()) { - { Text("Grade is required", color = MaterialTheme.colorScheme.error) } - } else null - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - availableGrades.forEach { grade -> - DropdownMenuItem( - text = { Text(grade) }, - onClick = { - newProblemGrade = grade - expanded = false - } - ) + text = + result.name.lowercase().replaceFirstChar { + it.uppercase() + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = + if (selectedResult == result) FontWeight.Medium + else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface + ) + } } } } - } - } - } - } - - // Result Selection (always shown) - item { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - 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) - ) - ) { - 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 + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + 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 + ) + ) + + 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 + ) + ) + } + Row( + 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 ) - .padding(vertical = 4.dp) ) { - RadioButton( - selected = selectedResult == result, - onClick = null, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary + 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 + } + ) + + val newProblem = + Problem.create( + gymId = gym.id, + name = newProblemName.ifBlank { null }, + climbType = selectedClimbType, + difficulty = difficulty + ) + + 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) + } + } 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 + ) ) - ) - 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("Add", fontWeight = FontWeight.Medium) } } } } - - - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - 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 - ) - ) - - 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 - ) - ) - } - } - Row( - 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) - } - - 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 - } - ) - - val newProblem = Problem.create( - gymId = gym.id, - name = newProblemName.ifBlank { null }, - climbType = selectedClimbType, - difficulty = difficulty - ) - - 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) - } - } 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) - ) - ) { - Text("Add", fontWeight = FontWeight.Medium) - } } } } } -} - } } diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 0b8585e..9c2281d 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -193,6 +193,18 @@ class ClimbViewModel( } } + fun deleteAttempt(attempt: Attempt) { + viewModelScope.launch { + repository.deleteAttempt(attempt) + } + } + + fun updateAttempt(attempt: Attempt) { + viewModelScope.launch { + repository.updateAttempt(attempt) + } + } + fun getAttemptsBySession(sessionId: String): Flow> = repository.getAttemptsBySession(sessionId) diff --git a/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt index 4ce87a2..08cead9 100644 --- a/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt +++ b/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -37,9 +37,18 @@ object ImageUtils { return try { val inputStream = context.contentResolver.openInputStream(imageUri) inputStream?.use { input -> - // Decode and compress the image + // Decode with options to get EXIF data + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + input.reset() + BitmapFactory.decodeStream(input, null, options) + + // Reset stream and decode with proper orientation + input.reset() val originalBitmap = BitmapFactory.decodeStream(input) - val compressedBitmap = compressImage(originalBitmap) + val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) + val compressedBitmap = compressImage(orientedBitmap) // Generate unique filename val filename = "${UUID.randomUUID()}.jpg" @@ -52,6 +61,9 @@ object ImageUtils { // Clean up bitmaps originalBitmap.recycle() + if (orientedBitmap != originalBitmap) { + orientedBitmap.recycle() + } compressedBitmap.recycle() // Return relative path @@ -63,6 +75,60 @@ object ImageUtils { } } + /** + * Corrects image orientation based on EXIF data + */ + private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap { + return try { + val inputStream = context.contentResolver.openInputStream(imageUri) + inputStream?.use { input -> + val exif = android.media.ExifInterface(input) + val orientation = exif.getAttributeInt( + android.media.ExifInterface.TAG_ORIENTATION, + android.media.ExifInterface.ORIENTATION_NORMAL + ) + + val matrix = android.graphics.Matrix() + when (orientation) { + android.media.ExifInterface.ORIENTATION_ROTATE_90 -> { + matrix.postRotate(90f) + } + android.media.ExifInterface.ORIENTATION_ROTATE_180 -> { + matrix.postRotate(180f) + } + android.media.ExifInterface.ORIENTATION_ROTATE_270 -> { + matrix.postRotate(270f) + } + android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { + matrix.postScale(-1f, 1f) + } + android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.postScale(1f, -1f) + } + android.media.ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + android.media.ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(-90f) + matrix.postScale(-1f, 1f) + } + } + + if (matrix.isIdentity) { + bitmap + } else { + android.graphics.Bitmap.createBitmap( + bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true + ) + } + } ?: bitmap + } catch (e: Exception) { + e.printStackTrace() + bitmap + } + } + /** * Compresses and resizes an image bitmap */ diff --git a/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt index b20ca13..5305093 100644 --- a/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt +++ b/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt @@ -42,15 +42,21 @@ object SessionShareUtils { val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() val attemptedProblems = problems.filter { it.id in uniqueProblems } - val averageGrade = if (attemptedProblems.isNotEmpty()) { - // This is a simplified average - in reality you'd need proper grade conversion - val gradeValues = attemptedProblems.mapNotNull { problem -> - problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() - } - if (gradeValues.isNotEmpty()) { - "V${gradeValues.average().roundToInt()}" - } else null - } else null + + // Calculate separate averages for different climbing types and difficulty systems + val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } + val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } + + val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder") + val ropeAverage = calculateAverageGrade(ropeProblems, "Rope") + + // Combine averages for display + val averageGrade = when { + boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage" + boulderAverage != null -> boulderAverage + ropeAverage != null -> ropeAverage + else -> null + } val duration = if (session.duration != null) "${session.duration}m" else "Unknown" val topResult = attempts.maxByOrNull { @@ -73,6 +79,76 @@ object SessionShareUtils { topResult = topResult ) } + + /** + * Calculate average grade for a specific set of problems, respecting their difficulty systems + */ + private fun calculateAverageGrade(problems: List, climbingType: String): String? { + if (problems.isEmpty()) return null + + // Group problems by difficulty system + val problemsBySystem = problems.groupBy { it.difficulty.system } + + val averages = mutableListOf() + + problemsBySystem.forEach { (system, systemProblems) -> + when (system) { + DifficultySystem.V_SCALE -> { + val gradeValues = systemProblems.mapNotNull { problem -> + when { + problem.difficulty.grade == "VB" -> 0 + else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() + } + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average().roundToInt() + averages.add(if (avg == 0) "VB" else "V$avg") + } + } + DifficultySystem.FONT -> { + val gradeValues = systemProblems.mapNotNull { problem -> + // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7) + problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average().roundToInt() + averages.add("$avg") + } + } + DifficultySystem.YDS -> { + val gradeValues = systemProblems.mapNotNull { problem -> + // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) + val grade = problem.difficulty.grade + if (grade.startsWith("5.")) { + grade.substring(2).toDoubleOrNull() + } else null + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average() + averages.add("5.${String.format("%.1f", avg)}") + } + } + DifficultySystem.CUSTOM -> { + // For custom systems, try to extract numeric values + val gradeValues = systemProblems.mapNotNull { problem -> + problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() + } + if (gradeValues.isNotEmpty()) { + val avg = gradeValues.average() + averages.add(String.format("%.1f", avg)) + } + } + } + } + + return if (averages.isNotEmpty()) { + if (averages.size == 1) { + averages.first() + } else { + averages.joinToString(" / ") + } + } else null + } fun generateShareCard( context: Context,