0.3.0 - Filtering and Better Scales
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,29 +187,38 @@ fun AddEditGymScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
DifficultySystem.entries.forEach { system ->
|
if (selectedClimbTypes.isEmpty()) {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = "Select climb types first to see available difficulty systems",
|
||||||
modifier = Modifier
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
.fillMaxWidth()
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.selectable(
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
selected = system in selectedDifficultySystems,
|
)
|
||||||
onClick = {
|
} else {
|
||||||
selectedDifficultySystems = if (system in selectedDifficultySystems) {
|
availableDifficultySystems.forEach { system ->
|
||||||
selectedDifficultySystems - system
|
Row(
|
||||||
} else {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
selectedDifficultySystems + system
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
},
|
.selectable(
|
||||||
role = Role.Checkbox
|
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
|
||||||
)
|
)
|
||||||
) {
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Checkbox(
|
Text(system.getDisplayName())
|
||||||
checked = system in selectedDifficultySystems,
|
}
|
||||||
onCheckedChange = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(system.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,23 +550,51 @@ fun AddEditProblemScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||||
value = difficultyGrade,
|
OutlinedTextField(
|
||||||
onValueChange = { difficultyGrade = it },
|
value = difficultyGrade,
|
||||||
label = { Text("Grade *") },
|
onValueChange = { difficultyGrade = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
label = { Text("Grade *") },
|
||||||
singleLine = true,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = {
|
singleLine = true,
|
||||||
Text(when (selectedDifficultySystem) {
|
placeholder = { Text("Enter custom grade") }
|
||||||
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
|
)
|
||||||
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
|
} else {
|
||||||
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
|
var expanded by remember { mutableStateOf(false) }
|
||||||
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
|
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||||
DifficultySystem.CUSTOM -> "Custom grade"
|
|
||||||
else -> "Enter grade"
|
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<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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,31 +1666,63 @@ fun EnhancedAddAttemptDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
if (selectedDifficultySystem == DifficultySystem.CUSTOM) {
|
||||||
value = newProblemGrade,
|
OutlinedTextField(
|
||||||
onValueChange = { newProblemGrade = it },
|
value = newProblemGrade,
|
||||||
label = { Text("Grade *") },
|
onValueChange = { newProblemGrade = it },
|
||||||
placeholder = {
|
label = { Text("Grade *") },
|
||||||
Text(when (selectedDifficultySystem) {
|
placeholder = { Text("Enter custom grade") },
|
||||||
DifficultySystem.V_SCALE -> "e.g., V0, V4, V10"
|
modifier = Modifier.fillMaxWidth(),
|
||||||
DifficultySystem.FONT -> "e.g., 3, 6A+, 8B"
|
singleLine = true,
|
||||||
DifficultySystem.YDS -> "e.g., 5.8, 5.12a"
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
DifficultySystem.FRENCH -> "e.g., 6a, 7c+"
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
DifficultySystem.CUSTOM -> "Custom grade"
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||||
else -> "Enter grade"
|
),
|
||||||
})
|
isError = newProblemGrade.isBlank(),
|
||||||
},
|
supportingText = if (newProblemGrade.isBlank()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
{ Text("Grade is required", color = MaterialTheme.colorScheme.error) }
|
||||||
singleLine = true,
|
} else null
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
)
|
||||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
} else {
|
||||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
var expanded by remember { mutableStateOf(false) }
|
||||||
),
|
val availableGrades = selectedDifficultySystem.getAvailableGrades()
|
||||||
isError = newProblemGrade.isBlank(),
|
|
||||||
supportingText = if (newProblemGrade.isBlank()) {
|
ExposedDropdownMenuBox(
|
||||||
{ Text("Grade is required", color = MaterialTheme.colorScheme.error) }
|
expanded = expanded,
|
||||||
} else null
|
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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user