diff --git a/.gradle/8.11.1/executionHistory/executionHistory.bin b/.gradle/8.11.1/executionHistory/executionHistory.bin index 9082f3d..d07e872 100644 Binary files a/.gradle/8.11.1/executionHistory/executionHistory.bin and b/.gradle/8.11.1/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.11.1/executionHistory/executionHistory.lock b/.gradle/8.11.1/executionHistory/executionHistory.lock index 777c4ad..e4ae4ba 100644 Binary files a/.gradle/8.11.1/executionHistory/executionHistory.lock and b/.gradle/8.11.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin index 992c3a5..3d48844 100644 Binary files a/.gradle/8.11.1/fileHashes/fileHashes.bin and b/.gradle/8.11.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock index afca945..806fb38 100644 Binary files a/.gradle/8.11.1/fileHashes/fileHashes.lock and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin index 5e3c92b..9f19ded 100644 Binary files a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin and b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index e80a677..5d57690 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe index f3056cd..e40efa0 100644 Binary files a/.gradle/file-system.probe and b/.gradle/file-system.probe differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c7b3eb..eeb49ae 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 = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt b/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt index 47813c1..cfd991d 100644 --- a/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt +++ b/app/src/main/java/com/atridad/openclimb/data/model/ClimbType.kt @@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable @Serializable enum class ClimbType { ROPE, - BOULDER + BOULDER; + + /** + * Get the display name for the UI + */ + fun getDisplayName(): String = when (this) { + ROPE -> "Rope" + BOULDER -> "Bouldering" + } } 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 f7fb35d..e89b87b 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 @@ -4,23 +4,68 @@ import kotlinx.serialization.Serializable @Serializable enum class DifficultySystem { - // Rope climbing systems - YDS, // Yosemite Decimal System (5.1 - 5.15d) - FRENCH, // French system (3 - 9c+) - UIAA, // UIAA system (I - XII+) - BRITISH, // British system (Mod - E11) - // Bouldering systems V_SCALE, // V-Scale (VB - V17) - FONT, // Fontainebleau (3 - 9A+) + FONT, // Fontainebleau (3 - 8C+) + + // Rope climbing systems + YDS, // Yosemite Decimal System (5.0 - 5.15d) // Custom system for gyms that use their own colors/naming - CUSTOM + CUSTOM; + + /** + * Get the display name for the UI + */ + fun getDisplayName(): String = when (this) { + V_SCALE -> "V Scale" + FONT -> "Font Scale" + YDS -> "YDS (Yosemite)" + CUSTOM -> "Custom" + } + + /** + * Check if this system is for bouldering + */ + fun isBoulderingSystem(): Boolean = when (this) { + V_SCALE, FONT -> true + YDS -> false + CUSTOM -> true // Custom is available for all + } + + /** + * Check if this system is for rope climbing + */ + fun isRopeSystem(): Boolean = when (this) { + YDS -> true + V_SCALE, FONT -> false + CUSTOM -> true // Custom is available for all + } + + /** + * Get available grades for this difficulty system + */ + fun getAvailableGrades(): List = when (this) { + V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17") + FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+") + YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d") + CUSTOM -> emptyList() // Custom allows free text input + } + + companion object { + /** + * Get all difficulty systems available for a specific climb type + */ + fun getSystemsForClimbType(climbType: ClimbType): List = when (climbType) { + ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } + ClimbType.ROPE -> entries.filter { it.isRopeSystem() } + } + } } @Serializable data class DifficultyGrade( val system: DifficultySystem, val grade: String, - val numericValue: Int // For comparison and analytics + val numericValue: Int ) 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 4dcfd98..357d4b6 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -28,7 +28,6 @@ fun OpenClimbApp() { val navController = rememberNavController() val context = LocalContext.current val currentBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = currentBackStackEntry?.destination?.route val database = remember { OpenClimbDatabase.getDatabase(context) } val repository = remember { ClimbRepository(database, context) } @@ -247,17 +246,15 @@ fun OpenClimbBottomNavigation(navController: NavHostController) { selected = isSelected, onClick = { navController.navigate(item.screen) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(Screen.Sessions) { - saveState = true + // Clear the entire back stack and go to the selected tab's root screen + popUpTo(0) { + inclusive = true } // Avoid multiple copies of the same destination when // reselecting the same item launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true + // Don't restore state - always start fresh when switching tabs + restoreState = false } } ) 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 c629703..7cf7cb0 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 @@ -47,6 +47,34 @@ fun AddEditGymScreen( val isEditing = gymId != null + // Calculate available difficulty systems based on selected climb types + val availableDifficultySystems = if (selectedClimbTypes.isEmpty()) { + emptyList() + } else { + selectedClimbTypes.flatMap { climbType -> + DifficultySystem.getSystemsForClimbType(climbType) + }.distinct() + } + + // Reset selected difficulty systems when available systems change + LaunchedEffect(availableDifficultySystems) { + selectedDifficultySystems = selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet() + } + + // Load existing gym data for editing + LaunchedEffect(gymId) { + if (gymId != null) { + val existingGym = viewModel.getGymById(gymId).first() + existingGym?.let { gym -> + name = gym.name + location = gym.location ?: "" + notes = gym.notes ?: "" + selectedClimbTypes = gym.supportedClimbTypes.toSet() + selectedDifficultySystems = gym.difficultySystems.toSet() + } + } + } + Scaffold( topBar = { TopAppBar( @@ -59,20 +87,16 @@ fun AddEditGymScreen( actions = { TextButton( onClick = { - val gym = if (isEditing) { - Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) - } else { - Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) - } + val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) if (isEditing) { - viewModel.updateGym(gym) + viewModel.updateGym(gym.copy(id = gymId!!)) } else { viewModel.addGym(gym) } onNavigateBack() }, - enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() + enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty() ) { Text("Save") } @@ -142,7 +166,7 @@ fun AddEditGymScreen( onCheckedChange = null ) Spacer(modifier = Modifier.width(8.dp)) - Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) + Text(climbType.getDisplayName()) } } } @@ -163,29 +187,38 @@ fun AddEditGymScreen( Spacer(modifier = Modifier.height(8.dp)) - DifficultySystem.entries.forEach { system -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = system in selectedDifficultySystems, - onClick = { - selectedDifficultySystems = if (system in selectedDifficultySystems) { - selectedDifficultySystems - system - } else { - selectedDifficultySystems + system - } - }, - role = Role.Checkbox + if (selectedClimbTypes.isEmpty()) { + Text( + text = "Select climb types first to see available difficulty systems", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + availableDifficultySystems.forEach { system -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = system in selectedDifficultySystems, + onClick = { + selectedDifficultySystems = if (system in selectedDifficultySystems) { + selectedDifficultySystems - system + } else { + selectedDifficultySystems + system + } + }, + role = Role.Checkbox + ) + ) { + Checkbox( + checked = system in selectedDifficultySystems, + onCheckedChange = null ) - ) { - Checkbox( - checked = system in selectedDifficultySystems, - onCheckedChange = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(system.name) + Spacer(modifier = Modifier.width(8.dp)) + Text(system.getDisplayName()) + } } } } @@ -244,6 +277,8 @@ 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 } } } } @@ -254,8 +289,39 @@ fun AddEditProblemScreen( } } - val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList() val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() + val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> + selectedGym?.difficultySystems?.contains(system) ?: true + } + + // Auto-select climb type if there's only one available + LaunchedEffect(availableClimbTypes) { + if (availableClimbTypes.size == 1 && selectedClimbType != availableClimbTypes.first()) { + selectedClimbType = availableClimbTypes.first() + } + } + + // Auto-select or reset difficulty system based on climb type + LaunchedEffect(selectedClimbType, availableDifficultySystems) { + when { + // If current system is not compatible, select the first available one + selectedDifficultySystem !in availableDifficultySystems -> { + selectedDifficultySystem = availableDifficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM + } + // If there's only one available system and nothing is selected, auto-select it + availableDifficultySystems.size == 1 && selectedDifficultySystem != availableDifficultySystems.first() -> { + selectedDifficultySystem = availableDifficultySystems.first() + } + } + } + + // Reset grade when difficulty system changes (unless it's a valid grade for the new system) + LaunchedEffect(selectedDifficultySystem) { + val availableGrades = selectedDifficultySystem.getAvailableGrades() + if (availableGrades.isNotEmpty() && difficultyGrade !in availableGrades) { + difficultyGrade = "" + } + } Scaffold( topBar = { @@ -437,7 +503,7 @@ fun AddEditProblemScreen( availableClimbTypes.forEach { climbType -> FilterChip( onClick = { selectedClimbType = climbType }, - label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) }, + label = { Text(climbType.getDisplayName()) }, selected = selectedClimbType == climbType ) } @@ -476,7 +542,7 @@ fun AddEditProblemScreen( items(availableDifficultySystems) { system -> FilterChip( onClick = { selectedDifficultySystem = system }, - label = { Text(system.name) }, + label = { Text(system.getDisplayName()) }, selected = selectedDifficultySystem == system ) } @@ -484,23 +550,51 @@ fun AddEditProblemScreen( Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = difficultyGrade, - onValueChange = { difficultyGrade = it }, - label = { Text("Grade *") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { - Text(when (selectedDifficultySystem) { - DifficultySystem.V_SCALE -> "e.g., V0, V4, V10" - DifficultySystem.FONT -> "e.g., 3, 6A+, 8B" - DifficultySystem.YDS -> "e.g., 5.8, 5.12a" - DifficultySystem.FRENCH -> "e.g., 6a, 7c+" - DifficultySystem.CUSTOM -> "Custom grade" - else -> "Enter grade" - }) + if (selectedDifficultySystem == DifficultySystem.CUSTOM) { + OutlinedTextField( + value = difficultyGrade, + onValueChange = { difficultyGrade = it }, + label = { Text("Grade *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Enter custom grade") } + ) + } else { + var expanded by remember { mutableStateOf(false) } + val availableGrades = selectedDifficultySystem.getAvailableGrades() + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = difficultyGrade, + onValueChange = { }, + readOnly = true, + label = { Text("Grade *") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availableGrades.forEach { grade -> + DropdownMenuItem( + text = { Text(grade) }, + onClick = { + difficultyGrade = grade + expanded = false + } + ) + } + } } - ) + } } } } @@ -617,6 +711,19 @@ fun AddEditSessionScreen( var attempts by remember { mutableStateOf(listOf()) } var showAddAttemptDialog by remember { mutableStateOf(false) } + // Load existing session data for editing + LaunchedEffect(sessionId) { + if (sessionId != null) { + val existingSession = viewModel.getSessionById(sessionId).first() + existingSession?.let { session -> + selectedGym = gyms.find { it.id == session.gymId } + sessionDate = session.date.split("T")[0] // Extract date part + duration = session.duration?.toString() ?: "" + sessionNotes = session.notes ?: "" + } + } + } + LaunchedEffect(gymId, gyms) { if (gymId != null && selectedGym == null) { selectedGym = gyms.find { it.id == gymId } @@ -830,7 +937,7 @@ fun AddEditSessionScreen( problem?.difficulty?.let { difficulty -> Text( - text = "${difficulty.system.name}: ${difficulty.grade}", + text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) @@ -956,7 +1063,7 @@ fun AddAttemptDialog( fontWeight = FontWeight.Medium ) Text( - text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", + text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) 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 cebb369..8dea6fa 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 @@ -481,7 +481,7 @@ fun ProblemDetailScreen( Column { problem?.let { p -> Text( - text = "${p.difficulty.system.name}: ${p.difficulty.grade}", + text = "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold @@ -490,7 +490,7 @@ fun ProblemDetailScreen( problem?.let { p -> Text( - text = p.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + text = p.climbType.getDisplayName(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -1314,7 +1314,7 @@ fun SessionAttemptCard( ) Text( - text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", + text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) @@ -1406,6 +1406,39 @@ fun EnhancedAddAttemptDialog( var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } 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()) { + 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) + } + + when { + // If current system is not compatible, select the first available one + selectedDifficultySystem !in availableSystems -> { + selectedDifficultySystem = availableSystems.firstOrNull() ?: gym.difficultySystems.firstOrNull() ?: DifficultySystem.CUSTOM + } + // If there's only one available system, auto-select it + availableSystems.size == 1 && selectedDifficultySystem != availableSystems.first() -> { + selectedDifficultySystem = availableSystems.first() + } + } + } + + // Reset grade when difficulty system changes + LaunchedEffect(selectedDifficultySystem) { + val availableGrades = selectedDifficultySystem.getAvailableGrades() + if (availableGrades.isNotEmpty() && newProblemGrade !in availableGrades) { + newProblemGrade = "" + } + } + Dialog(onDismissRequest = onDismiss) { Card( modifier = Modifier @@ -1509,7 +1542,7 @@ fun EnhancedAddAttemptDialog( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", + text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}", style = MaterialTheme.typography.bodyMedium, color = if (isSelected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) @@ -1584,7 +1617,7 @@ fun EnhancedAddAttemptDialog( onClick = { selectedClimbType = climbType }, label = { Text( - climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + climbType.getDisplayName(), fontWeight = FontWeight.Medium ) }, @@ -1611,12 +1644,15 @@ fun EnhancedAddAttemptDialog( LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(gym.difficultySystems) { system -> + val availableSystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> + gym.difficultySystems.contains(system) + } + items(availableSystems) { system -> FilterChip( onClick = { selectedDifficultySystem = system }, label = { Text( - system.name, + system.getDisplayName(), fontWeight = FontWeight.Medium ) }, @@ -1630,31 +1666,63 @@ fun EnhancedAddAttemptDialog( } } - OutlinedTextField( - value = newProblemGrade, - onValueChange = { newProblemGrade = it }, - label = { Text("Grade *") }, - placeholder = { - Text(when (selectedDifficultySystem) { - DifficultySystem.V_SCALE -> "e.g., V0, V4, V10" - DifficultySystem.FONT -> "e.g., 3, 6A+, 8B" - DifficultySystem.YDS -> "e.g., 5.8, 5.12a" - DifficultySystem.FRENCH -> "e.g., 6a, 7c+" - DifficultySystem.CUSTOM -> "Custom grade" - else -> "Enter 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 - ) + 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 + } + ) + } + } + } + } } } } diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt index df14e38..137c2da 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/GymsScreen.kt @@ -109,7 +109,7 @@ fun GymCard( AssistChip( onClick = { }, label = { - Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) + Text(climbType.getDisplayName()) }, modifier = Modifier.padding(end = 4.dp) ) @@ -119,7 +119,7 @@ fun GymCard( if (gym.difficultySystems.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}", + text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt index 7ff2150..7b7d858 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt @@ -2,6 +2,7 @@ package com.atridad.openclimb.ui.screens import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -14,6 +15,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.atridad.openclimb.R +import com.atridad.openclimb.data.model.ClimbType +import com.atridad.openclimb.data.model.Gym import com.atridad.openclimb.data.model.Problem import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.openclimb.ui.components.ImageDisplay @@ -32,6 +35,17 @@ fun ProblemsScreen( var selectedImagePaths by remember { mutableStateOf>(emptyList()) } var selectedImageIndex by remember { mutableStateOf(0) } + // Filter state + var selectedClimbType by remember { mutableStateOf(null) } + var selectedGym by remember { mutableStateOf(null) } + + // Apply filters + val filteredProblems = problems.filter { problem -> + val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } ?: true + val gymMatch = selectedGym?.let { it.id == problem.gymId } ?: true + climbTypeMatch && gymMatch + } + Column( modifier = Modifier .fillMaxSize() @@ -57,16 +71,113 @@ fun ProblemsScreen( Spacer(modifier = Modifier.height(16.dp)) - if (problems.isEmpty()) { + // Filters Section + if (problems.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Filters", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Climb Type Filter + Text( + text = "Climb Type", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + FilterChip( + onClick = { selectedClimbType = null }, + label = { Text("All Types") }, + selected = selectedClimbType == null + ) + } + items(ClimbType.entries) { climbType -> + FilterChip( + onClick = { selectedClimbType = climbType }, + label = { Text(climbType.getDisplayName()) }, + selected = selectedClimbType == climbType + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Gym Filter + Text( + text = "Gym", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + FilterChip( + onClick = { selectedGym = null }, + label = { Text("All Gyms") }, + selected = selectedGym == null + ) + } + items(gyms) { gym -> + FilterChip( + onClick = { selectedGym = gym }, + label = { Text(gym.name) }, + selected = selectedGym?.id == gym.id + ) + } + } + + // Filter result count + if (selectedClimbType != null || selectedGym != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Showing ${filteredProblems.size} of ${problems.size} problems", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + if (filteredProblems.isEmpty()) { EmptyStateMessage( - title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet", - message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!", + title = if (problems.isEmpty()) { + if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet" + } else { + "No Problems Match Filters" + }, + message = if (problems.isEmpty()) { + if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!" + } else { + "Try adjusting your filters to see more problems." + }, onActionClick = { }, actionText = "" ) } else { LazyColumn { - items(problems) { problem -> + items(filteredProblems) { problem -> ProblemCard( problem = problem, gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", @@ -138,7 +249,7 @@ fun ProblemCard( ) Text( - text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, + text = problem.climbType.getDisplayName(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant )