0.3.0 - Filtering and Better Scales

This commit is contained in:
2025-08-15 19:30:50 -06:00
parent 10984226d9
commit 491a122c01
15 changed files with 446 additions and 110 deletions

Binary file not shown.

View File

@@ -14,8 +14,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 3
versionName = "0.2.0" versionName = "0.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -5,5 +5,13 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class ClimbType { enum class ClimbType {
ROPE, ROPE,
BOULDER BOULDER;
/**
* Get the display name for the UI
*/
fun getDisplayName(): String = when (this) {
ROPE -> "Rope"
BOULDER -> "Bouldering"
}
} }

View File

@@ -4,23 +4,68 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class DifficultySystem { 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 // Bouldering systems
V_SCALE, // V-Scale (VB - V17) 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 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<String> = 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<DifficultySystem> = when (climbType) {
ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() }
ClimbType.ROPE -> entries.filter { it.isRopeSystem() }
}
}
} }
@Serializable @Serializable
data class DifficultyGrade( data class DifficultyGrade(
val system: DifficultySystem, val system: DifficultySystem,
val grade: String, val grade: String,
val numericValue: Int // For comparison and analytics val numericValue: Int
) )

View File

@@ -28,7 +28,6 @@ fun OpenClimbApp() {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStackEntry?.destination?.route
val database = remember { OpenClimbDatabase.getDatabase(context) } val database = remember { OpenClimbDatabase.getDatabase(context) }
val repository = remember { ClimbRepository(database, context) } val repository = remember { ClimbRepository(database, context) }
@@ -247,17 +246,15 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected, selected = isSelected,
onClick = { onClick = {
navController.navigate(item.screen) { navController.navigate(item.screen) {
// Pop up to the start destination of the graph to // Clear the entire back stack and go to the selected tab's root screen
// avoid building up a large stack of destinations popUpTo(0) {
// on the back stack as users select items inclusive = true
popUpTo(Screen.Sessions) {
saveState = true
} }
// Avoid multiple copies of the same destination when // Avoid multiple copies of the same destination when
// reselecting the same item // reselecting the same item
launchSingleTop = true launchSingleTop = true
// Restore state when reselecting a previously selected item // Don't restore state - always start fresh when switching tabs
restoreState = true restoreState = false
} }
} }
) )

View File

@@ -47,6 +47,34 @@ fun AddEditGymScreen(
val isEditing = gymId != null 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -59,20 +87,16 @@ fun AddEditGymScreen(
actions = { actions = {
TextButton( TextButton(
onClick = { onClick = {
val gym = if (isEditing) { val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
} else {
Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes)
}
if (isEditing) { if (isEditing) {
viewModel.updateGym(gym) viewModel.updateGym(gym.copy(id = gymId!!))
} else { } else {
viewModel.addGym(gym) viewModel.addGym(gym)
} }
onNavigateBack() onNavigateBack()
}, },
enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty()
) { ) {
Text("Save") Text("Save")
} }
@@ -142,7 +166,7 @@ fun AddEditGymScreen(
onCheckedChange = null onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) Text(climbType.getDisplayName())
} }
} }
} }
@@ -163,7 +187,15 @@ fun AddEditGymScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
DifficultySystem.entries.forEach { system -> 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -185,7 +217,8 @@ fun AddEditGymScreen(
onCheckedChange = null onCheckedChange = null
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(system.name) Text(system.getDisplayName())
}
} }
} }
} }
@@ -244,6 +277,8 @@ fun AddEditProblemScreen(
notes = p.notes ?: "" notes = p.notes ?: ""
isActive = p.isActive isActive = p.isActive
imagePaths = p.imagePaths imagePaths = p.imagePaths
// Set the selected gym for the existing problem
selectedGym = gyms.find { it.id == p.gymId }
} }
} }
} }
@@ -254,8 +289,39 @@ fun AddEditProblemScreen(
} }
} }
val availableDifficultySystems = selectedGym?.difficultySystems ?: DifficultySystem.entries.toList()
val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.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( Scaffold(
topBar = { topBar = {
@@ -437,7 +503,7 @@ fun AddEditProblemScreen(
availableClimbTypes.forEach { climbType -> availableClimbTypes.forEach { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) }, label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
@@ -476,7 +542,7 @@ fun AddEditProblemScreen(
items(availableDifficultySystems) { system -> items(availableDifficultySystems) { system ->
FilterChip( FilterChip(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { Text(system.name) }, label = { Text(system.getDisplayName()) },
selected = selectedDifficultySystem == system selected = selectedDifficultySystem == system
) )
} }
@@ -484,27 +550,55 @@ fun AddEditProblemScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField( OutlinedTextField(
value = difficultyGrade, value = difficultyGrade,
onValueChange = { difficultyGrade = it }, onValueChange = { difficultyGrade = it },
label = { Text("Grade *") }, label = { Text("Grade *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { placeholder = { Text("Enter custom grade") }
Text(when (selectedDifficultySystem) { )
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10" } else {
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B" var expanded by remember { mutableStateOf(false) }
DifficultySystem.YDS -> "e.g., 5.8, 5.12a" val availableGrades = selectedDifficultySystem.getAvailableGrades()
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
DifficultySystem.CUSTOM -> "Custom grade" ExposedDropdownMenuBox(
else -> "Enter grade" 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
} }
) )
} }
} }
} }
} }
}
}
}
}
// Images Section // Images Section
item { item {
@@ -617,6 +711,19 @@ fun AddEditSessionScreen(
var attempts by remember { mutableStateOf(listOf<AttemptInput>()) } var attempts by remember { mutableStateOf(listOf<AttemptInput>()) }
var showAddAttemptDialog by remember { mutableStateOf(false) } 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) { LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) { if (gymId != null && selectedGym == null) {
selectedGym = gyms.find { it.id == gymId } selectedGym = gyms.find { it.id == gymId }
@@ -830,7 +937,7 @@ fun AddEditSessionScreen(
problem?.difficulty?.let { difficulty -> problem?.difficulty?.let { difficulty ->
Text( Text(
text = "${difficulty.system.name}: ${difficulty.grade}", text = "${difficulty.system.getDisplayName()}: ${difficulty.grade}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -956,7 +1063,7 @@ fun AddAttemptDialog(
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Text( Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )

View File

@@ -481,7 +481,7 @@ fun ProblemDetailScreen(
Column { Column {
problem?.let { p -> problem?.let { p ->
Text( Text(
text = "${p.difficulty.system.name}: ${p.difficulty.grade}", text = "${p.difficulty.system.getDisplayName()}: ${p.difficulty.grade}",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -490,7 +490,7 @@ fun ProblemDetailScreen(
problem?.let { p -> problem?.let { p ->
Text( Text(
text = p.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, text = p.climbType.getDisplayName(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -1314,7 +1314,7 @@ fun SessionAttemptCard(
) )
Text( Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -1406,6 +1406,39 @@ fun EnhancedAddAttemptDialog(
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } 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()) {
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) { Dialog(onDismissRequest = onDismiss) {
Card( Card(
modifier = Modifier modifier = Modifier
@@ -1509,7 +1542,7 @@ fun EnhancedAddAttemptDialog(
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "${problem.difficulty.system.name}: ${problem.difficulty.grade}", text = "${problem.difficulty.system.getDisplayName()}: ${problem.difficulty.grade}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) color = if (isSelected)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
@@ -1584,7 +1617,7 @@ fun EnhancedAddAttemptDialog(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { label = {
Text( Text(
climbType.name.lowercase().replaceFirstChar { it.uppercase() }, climbType.getDisplayName(),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1611,12 +1644,15 @@ fun EnhancedAddAttemptDialog(
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(gym.difficultySystems) { system -> val availableSystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system ->
gym.difficultySystems.contains(system)
}
items(availableSystems) { system ->
FilterChip( FilterChip(
onClick = { selectedDifficultySystem = system }, onClick = { selectedDifficultySystem = system },
label = { label = {
Text( Text(
system.name, system.getDisplayName(),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
}, },
@@ -1630,20 +1666,12 @@ fun EnhancedAddAttemptDialog(
} }
} }
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
OutlinedTextField( OutlinedTextField(
value = newProblemGrade, value = newProblemGrade,
onValueChange = { newProblemGrade = it }, onValueChange = { newProblemGrade = it },
label = { Text("Grade *") }, label = { Text("Grade *") },
placeholder = { placeholder = { Text("Enter custom grade") },
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(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
@@ -1655,6 +1683,46 @@ fun EnhancedAddAttemptDialog(
{ Text("Grade is required", color = MaterialTheme.colorScheme.error) } { Text("Grade is required", color = MaterialTheme.colorScheme.error) }
} else null } 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
}
)
}
}
}
}
} }
} }
} }

View File

@@ -109,7 +109,7 @@ fun GymCard(
AssistChip( AssistChip(
onClick = { }, onClick = { },
label = { label = {
Text(climbType.name.lowercase().replaceFirstChar { it.uppercase() }) Text(climbType.getDisplayName())
}, },
modifier = Modifier.padding(end = 4.dp) modifier = Modifier.padding(end = 4.dp)
) )
@@ -119,7 +119,7 @@ fun GymCard(
if (gym.difficultySystems.isNotEmpty()) { if (gym.difficultySystems.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.name }}", text = "Systems: ${gym.difficultySystems.joinToString(", ") { it.getDisplayName() }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -2,6 +2,7 @@ package com.atridad.openclimb.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@@ -14,6 +15,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.ui.components.FullscreenImageViewer import com.atridad.openclimb.ui.components.FullscreenImageViewer
import com.atridad.openclimb.ui.components.ImageDisplay import com.atridad.openclimb.ui.components.ImageDisplay
@@ -32,6 +35,17 @@ fun ProblemsScreen(
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) } var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableStateOf(0) } var selectedImageIndex by remember { mutableStateOf(0) }
// Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
var selectedGym by remember { mutableStateOf<Gym?>(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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -57,16 +71,113 @@ fun ProblemsScreen(
Spacer(modifier = Modifier.height(16.dp)) 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( EmptyStateMessage(
title = if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet", title = if (problems.isEmpty()) {
message = if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!", if (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 = { }, onActionClick = { },
actionText = "" actionText = ""
) )
} else { } else {
LazyColumn { LazyColumn {
items(problems) { problem -> items(filteredProblems) { problem ->
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
@@ -138,7 +249,7 @@ fun ProblemCard(
) )
Text( Text(
text = problem.climbType.name.lowercase().replaceFirstChar { it.uppercase() }, text = problem.climbType.getDisplayName(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )