diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a777f93..1b83931 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,7 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 25 + versionCode = 26 versionName = "1.5.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt index 19697e4..6546258 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt @@ -7,62 +7,58 @@ import kotlinx.coroutines.flow.Flow @Dao interface ProblemDao { - + @Query("SELECT * FROM problems ORDER BY updatedAt DESC") fun getAllProblems(): Flow> - - @Query("SELECT * FROM problems WHERE id = :id") - suspend fun getProblemById(id: String): Problem? - + + @Query("SELECT * FROM problems WHERE id = :id") suspend fun getProblemById(id: String): Problem? + @Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC") fun getProblemsByGym(gymId: String): Flow> - + @Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC") fun getProblemsByClimbType(climbType: ClimbType): Flow> - - @Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC") + + @Query( + "SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC" + ) fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow> - + @Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC") fun getActiveProblems(): Flow> - + @Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC") fun getActiveProblemsByGym(gymId: String): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertProblem(problem: Problem) - + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblem(problem: Problem) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblems(problems: List) - - @Update - suspend fun updateProblem(problem: Problem) - - @Delete - suspend fun deleteProblem(problem: Problem) - - @Query("DELETE FROM problems WHERE id = :id") - suspend fun deleteProblemById(id: String) - + + @Update suspend fun updateProblem(problem: Problem) + + @Delete suspend fun deleteProblem(problem: Problem) + + @Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String) + @Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId") suspend fun getProblemsCountByGym(gymId: String): Int - + @Query("SELECT COUNT(*) FROM problems WHERE isActive = 1") suspend fun getActiveProblemsCount(): Int - - @Query(""" - SELECT * FROM problems - WHERE (name LIKE '%' || :searchQuery || '%' + + @Query( + """ + SELECT * FROM problems + WHERE (name LIKE '%' || :searchQuery || '%' OR description LIKE '%' || :searchQuery || '%' - OR location LIKE '%' || :searchQuery || '%' - OR setter LIKE '%' || :searchQuery || '%') + OR location LIKE '%' || :searchQuery || '%') ORDER BY updatedAt DESC - """) + """ + ) fun searchProblems(searchQuery: String): Flow> - - @Query("SELECT COUNT(*) FROM problems") - suspend fun getProblemsCount(): Int - - @Query("DELETE FROM problems") - suspend fun deleteAllProblems() + + @Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int + + @Query("DELETE FROM problems") suspend fun deleteAllProblems() } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt index 80463e9..1116f7f 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt @@ -4,71 +4,67 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable import java.time.LocalDateTime +import kotlinx.serialization.Serializable @Entity( - tableName = "problems", - foreignKeys = [ - ForeignKey( - entity = Gym::class, - parentColumns = ["id"], - childColumns = ["gymId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index(value = ["gymId"])] + tableName = "problems", + foreignKeys = + [ + ForeignKey( + entity = Gym::class, + parentColumns = ["id"], + childColumns = ["gymId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["gymId"])] ) @Serializable data class Problem( - @PrimaryKey - val id: String, - val gymId: String, - val name: String? = null, - val description: String? = null, - val climbType: ClimbType, - val difficulty: DifficultyGrade, - val setter: String? = null, - val tags: List = emptyList(), - val location: String? = null, - val imagePaths: List = emptyList(), - val isActive: Boolean = true, - val dateSet: String? = null, - val notes: String? = null, - val createdAt: String, - val updatedAt: String + @PrimaryKey val id: String, + val gymId: String, + val name: String? = null, + val description: String? = null, + val climbType: ClimbType, + val difficulty: DifficultyGrade, + val tags: List = emptyList(), + val location: String? = null, + val imagePaths: List = emptyList(), + val isActive: Boolean = true, + val dateSet: String? = null, + val notes: String? = null, + val createdAt: String, + val updatedAt: String ) { companion object { fun create( - gymId: String, - name: String? = null, - description: String? = null, - climbType: ClimbType, - difficulty: DifficultyGrade, - setter: String? = null, - tags: List = emptyList(), - location: String? = null, - imagePaths: List = emptyList(), - dateSet: String? = null, - notes: String? = null + gymId: String, + name: String? = null, + description: String? = null, + climbType: ClimbType, + difficulty: DifficultyGrade, + tags: List = emptyList(), + location: String? = null, + imagePaths: List = emptyList(), + dateSet: String? = null, + notes: String? = null ): Problem { val now = LocalDateTime.now().toString() return Problem( - id = java.util.UUID.randomUUID().toString(), - gymId = gymId, - name = name, - description = description, - climbType = climbType, - difficulty = difficulty, - setter = setter, - tags = tags, - location = location, - imagePaths = imagePaths, - isActive = true, - dateSet = dateSet, - notes = notes, - createdAt = now, - updatedAt = now + id = java.util.UUID.randomUUID().toString(), + gymId = gymId, + name = name, + description = description, + climbType = climbType, + difficulty = difficulty, + tags = tags, + location = location, + imagePaths = imagePaths, + isActive = true, + dateSet = dateSet, + notes = notes, + createdAt = now, + updatedAt = now ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 0670e30..e4d3e0d 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -82,7 +82,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) val exportData = ClimbDataExport( exportedAt = LocalDateTime.now().toString(), - version = "1.0", + version = "2.0", gyms = allGyms, problems = allProblems, sessions = allSessions, @@ -141,7 +141,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) val exportData = ClimbDataExport( exportedAt = LocalDateTime.now().toString(), - version = "1.0", + version = "2.0", gyms = allGyms, problems = allProblems, sessions = allSessions, @@ -343,7 +343,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) @kotlinx.serialization.Serializable data class ClimbDataExport( val exportedAt: String, - val version: String = "1.0", + val version: String = "2.0", val gyms: List, val problems: List, val sessions: List, diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt index abadcea..8fea3d0 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt @@ -12,46 +12,44 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext import com.atridad.openclimb.data.model.* import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.viewmodel.ClimbViewModel -import kotlinx.coroutines.flow.first import java.time.LocalDateTime +import kotlinx.coroutines.flow.first @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddEditGymScreen( - gymId: String?, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit -) { +fun AddEditGymScreen(gymId: String?, viewModel: ClimbViewModel, onNavigateBack: () -> Unit) { var name by remember { mutableStateOf("") } var location by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") } var selectedClimbTypes by remember { mutableStateOf(setOf()) } var selectedDifficultySystems by remember { mutableStateOf(setOf()) } - + 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() - } - + 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() + selectedDifficultySystems = + selectedDifficultySystems.filter { it in availableDifficultySystems }.toSet() } - + // Load existing gym data for editing LaunchedEffect(gymId) { if (gymId != null) { @@ -65,96 +63,105 @@ fun AddEditGymScreen( } } } - + Scaffold( - topBar = { - TopAppBar( - title = { Text(if (isEditing) "Edit Gym" else "Add Gym") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - TextButton( - onClick = { - val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) - - if (isEditing) { - viewModel.updateGym(gym.copy(id = gymId!!)) - } else { - viewModel.addGym(gym) + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Gym" else "Add Gym") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) } - onNavigateBack() }, - enabled = name.isNotBlank() && selectedClimbTypes.isNotEmpty() && selectedDifficultySystems.isNotEmpty() - ) { - Text("Save") - } - } - ) - } + actions = { + TextButton( + onClick = { + val gym = + Gym.create( + name, + location, + selectedClimbTypes.toList(), + selectedDifficultySystems.toList(), + notes = notes + ) + + if (isEditing) { + viewModel.updateGym(gym.copy(id = gymId!!)) + } else { + viewModel.addGym(gym) + } + onNavigateBack() + }, + enabled = + name.isNotBlank() && + selectedClimbTypes.isNotEmpty() && + selectedDifficultySystems.isNotEmpty() + ) { Text("Save") } + } + ) + } ) { paddingValues -> Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Name field OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Gym Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true + value = name, + onValueChange = { name = it }, + label = { Text("Gym Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) - + // Location field OutlinedTextField( - value = location, - onValueChange = { location = it }, - label = { Text("Location (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true + value = location, + onValueChange = { location = it }, + label = { Text("Location (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) - + // Climb Types - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Supported Climb Types", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Supported Climb Types", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + ClimbType.entries.forEach { climbType -> Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = climbType in selectedClimbTypes, - onClick = { - selectedClimbTypes = if (climbType in selectedClimbTypes) { - selectedClimbTypes - climbType - } else { - selectedClimbTypes + climbType - } - }, - role = Role.Checkbox - ) + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = climbType in selectedClimbTypes, + onClick = { + selectedClimbTypes = + if (climbType in + selectedClimbTypes + ) { + selectedClimbTypes - + climbType + } else { + selectedClimbTypes + + climbType + } + }, + role = Role.Checkbox + ) ) { Checkbox( - checked = climbType in selectedClimbTypes, - onCheckedChange = null + checked = climbType in selectedClimbTypes, + onCheckedChange = null ) Spacer(modifier = Modifier.width(8.dp)) Text(climbType.getDisplayName()) @@ -162,50 +169,54 @@ fun AddEditGymScreen( } } } - + // Difficulty Systems - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Difficulty Systems", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Difficulty Systems", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + 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) + 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 - ) + 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 + checked = system in selectedDifficultySystems, + onCheckedChange = null ) Spacer(modifier = Modifier.width(8.dp)) Text(system.getDisplayName()) @@ -214,14 +225,14 @@ fun AddEditGymScreen( } } } - + // Notes field OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - label = { Text("Notes (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 ) } } @@ -230,28 +241,30 @@ fun AddEditGymScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddEditProblemScreen( - problemId: String?, - gymId: String?, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit + problemId: String?, + gymId: String?, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit ) { val isEditing = problemId != null val gyms by viewModel.gyms.collectAsState() - + // Problem form state - var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } + var selectedGym by remember { + mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) + } var problemName by remember { mutableStateOf("") } var description by remember { mutableStateOf("") } var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } var selectedDifficultySystem by remember { mutableStateOf(DifficultySystem.V_SCALE) } var difficultyGrade by remember { mutableStateOf("") } - var setter by remember { mutableStateOf("") } + var location by remember { mutableStateOf("") } var tags by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") } var isActive by remember { mutableStateOf(true) } var imagePaths by remember { mutableStateOf>(emptyList()) } - + // Load existing problem data for editing LaunchedEffect(problemId) { if (problemId != null) { @@ -262,7 +275,7 @@ fun AddEditProblemScreen( selectedClimbType = p.climbType selectedDifficultySystem = p.difficulty.system difficultyGrade = p.difficulty.grade - setter = p.setter ?: "" + location = p.location ?: "" tags = p.tags.joinToString(", ") notes = p.notes ?: "" @@ -272,39 +285,42 @@ fun AddEditProblemScreen( } } } - + LaunchedEffect(gymId, gyms) { if (gymId != null && selectedGym == null) { selectedGym = gyms.find { it.id == gymId } } } - + val availableClimbTypes = selectedGym?.supportedClimbTypes ?: ClimbType.entries.toList() - val availableDifficultySystems = DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> - selectedGym?.difficultySystems?.contains(system) != false - } - + val availableDifficultySystems = + DifficultySystem.getSystemsForClimbType(selectedClimbType).filter { system -> + selectedGym?.difficultySystems?.contains(system) != false + } + // 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 + 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() -> { + 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() @@ -312,96 +328,108 @@ fun AddEditProblemScreen( difficultyGrade = "" } } - + Scaffold( - topBar = { - TopAppBar( - title = { Text(if (isEditing) "Edit Problem" else "Add Problem") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - TextButton( - onClick = { - selectedGym?.let { gym -> - val difficulty = DifficultyGrade( - system = selectedDifficultySystem, - grade = difficultyGrade, - numericValue = when (selectedDifficultySystem) { - DifficultySystem.V_SCALE -> difficultyGrade.removePrefix("V").toIntOrNull() ?: 0 - else -> difficultyGrade.hashCode() % 100 // Simple mapping for other systems - } + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Problem" else "Add Problem") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) - - val problem = Problem.create( - gymId = gym.id, - name = problemName.ifBlank { null }, - description = description.ifBlank { null }, - climbType = selectedClimbType, - difficulty = difficulty, - setter = setter.ifBlank { null }, - tags = tags.split(",").map { it.trim() }.filter { it.isNotBlank() }, - location = location.ifBlank { null }, - imagePaths = imagePaths, - notes = notes.ifBlank { null } - ) - - if (isEditing) { - viewModel.updateProblem(problem.copy(id = problemId!!)) - } else { - viewModel.addProblem(problem) - } - onNavigateBack() } }, - enabled = selectedGym != null && difficultyGrade.isNotBlank() - ) { - Text("Save") - } - } - ) - } + actions = { + TextButton( + onClick = { + selectedGym?.let { gym -> + val difficulty = + DifficultyGrade( + system = selectedDifficultySystem, + grade = difficultyGrade, + numericValue = + when (selectedDifficultySystem + ) { + DifficultySystem.V_SCALE -> + difficultyGrade + .removePrefix( + "V" + ) + .toIntOrNull() + ?: 0 + else -> + difficultyGrade + .hashCode() % + 100 // Simple mapping for other systems + } + ) + + val problem = + Problem.create( + gymId = gym.id, + name = problemName.ifBlank { null }, + description = + description.ifBlank { null }, + climbType = selectedClimbType, + difficulty = difficulty, + tags = + tags.split(",") + .map { it.trim() } + .filter { + it.isNotBlank() + }, + location = location.ifBlank { null }, + imagePaths = imagePaths, + notes = notes.ifBlank { null } + ) + + if (isEditing) { + viewModel.updateProblem( + problem.copy(id = problemId!!) + ) + } else { + viewModel.addProblem(problem) + } + onNavigateBack() + } + }, + enabled = selectedGym != null && difficultyGrade.isNotBlank() + ) { Text("Save") } + } + ) + } ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Gym Selection item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Select Gym", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Select Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + if (gyms.isEmpty()) { Text( - text = "No gyms available. Add a gym first.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + text = "No gyms available. Add a gym first.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error ) } else { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(gyms) { gym -> FilterChip( - onClick = { selectedGym = gym }, - label = { Text(gym.name) }, - selected = selectedGym?.id == gym.id + onClick = { selectedGym = gym }, + label = { Text(gym.name) }, + selected = selectedGym?.id == gym.id ) } } @@ -409,92 +437,74 @@ fun AddEditProblemScreen( } } } - + // Basic Problem Info item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Problem Details", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Problem Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(16.dp)) - + OutlinedTextField( - value = problemName, - onValueChange = { problemName = it }, - label = { Text("Problem Name (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") } + value = problemName, + onValueChange = { problemName = it }, + label = { Text("Problem Name (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., 'The Overhang Monster', 'Yellow V4'") } ) - + Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( - value = description, - onValueChange = { description = it }, - label = { Text("Description (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - placeholder = { Text("Describe the problem, holds, style, etc.") } + value = description, + onValueChange = { description = it }, + label = { Text("Description (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + placeholder = { Text("Describe the problem, holds, style, etc.") } ) - + Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = setter, - onValueChange = { setter = it }, - label = { Text("Route Setter (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - + Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( - value = location, - onValueChange = { location = it }, - label = { Text("Location (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") } + value = location, + onValueChange = { location = it }, + label = { Text("Location (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., 'Cave area', 'Wall 3', 'Right side'") } ) } } } - + // Climb Type if (selectedGym != null) { item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Climb Type", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Climb Type", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { availableClimbTypes.forEach { climbType -> FilterChip( - onClick = { selectedClimbType = climbType }, - label = { Text(climbType.getDisplayName()) }, - selected = selectedClimbType == climbType + onClick = { selectedClimbType = climbType }, + label = { Text(climbType.getDisplayName()) }, + selected = selectedClimbType == climbType ) } } @@ -502,91 +512,102 @@ fun AddEditProblemScreen( } } } - + // Difficulty if (selectedGym != null) { item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Difficulty", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Difficulty", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = "Difficulty System", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium + text = "Difficulty System", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(availableDifficultySystems) { system -> FilterChip( - onClick = { selectedDifficultySystem = system }, - label = { Text(system.getDisplayName()) }, - selected = selectedDifficultySystem == system + onClick = { selectedDifficultySystem = system }, + label = { Text(system.getDisplayName()) }, + selected = selectedDifficultySystem == system ) } } - + Spacer(modifier = Modifier.height(8.dp)) - + if (selectedDifficultySystem == DifficultySystem.CUSTOM) { OutlinedTextField( - value = difficultyGrade, - onValueChange = { newValue -> - // Only allow integers for custom scales - if (newValue.isEmpty() || newValue.all { it.isDigit() }) { - difficultyGrade = newValue - } - }, - label = { Text("Grade *") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Enter numeric grade (e.g. 5, 10, 15)") }, - supportingText = { Text("Custom grades must be whole numbers") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + value = difficultyGrade, + onValueChange = { newValue -> + // Only allow integers for custom scales + if (newValue.isEmpty() || newValue.all { it.isDigit() } + ) { + difficultyGrade = newValue + } + }, + label = { Text("Grade *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text("Enter numeric grade (e.g. 5, 10, 15)") + }, + supportingText = { + Text("Custom grades must be whole numbers") + }, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Number) ) } else { var expanded by remember { mutableStateOf(false) } val availableGrades = selectedDifficultySystem.getAvailableGrades() - + ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth() + 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(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true) - .fillMaxWidth() + value = difficultyGrade, + onValueChange = {}, + readOnly = true, + label = { Text("Grade *") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = + ExposedDropdownMenuDefaults + .outlinedTextFieldColors(), + modifier = + Modifier.menuAnchor( + androidx.compose.material3 + .MenuAnchorType + .PrimaryNotEditable, + enabled = true + ) + .fillMaxWidth() ) ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } + expanded = expanded, + onDismissRequest = { expanded = false } ) { availableGrades.forEach { grade -> DropdownMenuItem( - text = { Text(grade) }, - onClick = { - difficultyGrade = grade - expanded = false - } + text = { Text(grade) }, + onClick = { + difficultyGrade = grade + expanded = false + } ) } } @@ -596,87 +617,76 @@ fun AddEditProblemScreen( } } } - + // Images Section item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Photos", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Photos", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(16.dp)) - + ImagePicker( - imageUris = imagePaths, - onImagesChanged = { imagePaths = it }, - maxImages = 5 + imageUris = imagePaths, + onImagesChanged = { imagePaths = it }, + maxImages = 5 ) } } } item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Additional Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Additional Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(16.dp)) - + OutlinedTextField( - value = tags, - onValueChange = { tags = it }, - label = { Text("Tags (Optional)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") } + value = tags, + onValueChange = { tags = it }, + label = { Text("Tags (Optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("e.g., crimpy, dynamic (comma-separated)") } ) - + Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - label = { Text("Notes (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - placeholder = { Text("Any additional notes about this problem") } + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + placeholder = { Text("Any additional notes about this problem") } ) - + Spacer(modifier = Modifier.height(16.dp)) - + Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = isActive, - onClick = { isActive = !isActive }, - role = Role.Checkbox - ) + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .selectable( + selected = isActive, + onClick = { isActive = !isActive }, + role = Role.Checkbox + ) ) { - Checkbox( - checked = isActive, - onCheckedChange = null - ) + Checkbox(checked = isActive, onCheckedChange = null) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Problem is currently active", - style = MaterialTheme.typography.bodyMedium + text = "Problem is currently active", + style = MaterialTheme.typography.bodyMedium ) } } @@ -689,21 +699,23 @@ fun AddEditProblemScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddEditSessionScreen( - sessionId: String?, - gymId: String?, - viewModel: ClimbViewModel, - onNavigateBack: () -> Unit + sessionId: String?, + gymId: String?, + viewModel: ClimbViewModel, + onNavigateBack: () -> Unit ) { val isEditing = sessionId != null val gyms by viewModel.gyms.collectAsState() val context = LocalContext.current // Session form state - var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } + var selectedGym by remember { + mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) + } var sessionDate by remember { mutableStateOf(LocalDateTime.now().toLocalDate().toString()) } var duration by remember { mutableStateOf("") } var sessionNotes by remember { mutableStateOf("") } - + // Load existing session data for editing LaunchedEffect(sessionId) { if (sessionId != null) { @@ -716,84 +728,86 @@ fun AddEditSessionScreen( } } } - + LaunchedEffect(gymId, gyms) { if (gymId != null && selectedGym == null) { selectedGym = gyms.find { it.id == gymId } } } - + Scaffold( - topBar = { - TopAppBar( - title = { Text(if (isEditing) "Edit Session" else "Add Session") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - TextButton( - onClick = { - selectedGym?.let { gym -> - if (isEditing) { - val session = ClimbSession.create( - gymId = gym.id, - notes = sessionNotes.ifBlank { null } - ) - viewModel.updateSession(session.copy(id = sessionId!!)) - } else { - viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null }) - } - onNavigateBack() + topBar = { + TopAppBar( + title = { Text(if (isEditing) "Edit Session" else "Add Session") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) } }, - enabled = selectedGym != null - ) { - Text("Save") - } - } - ) - } + actions = { + TextButton( + onClick = { + selectedGym?.let { gym -> + if (isEditing) { + val session = + ClimbSession.create( + gymId = gym.id, + notes = + sessionNotes.ifBlank { + null + } + ) + viewModel.updateSession( + session.copy(id = sessionId!!) + ) + } else { + viewModel.startSession( + context, + gym.id, + sessionNotes.ifBlank { null } + ) + } + onNavigateBack() + } + }, + enabled = selectedGym != null + ) { Text("Save") } + } + ) + } ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Gym Selection item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Select Gym", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Select Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + if (gyms.isEmpty()) { Text( - text = "No gyms available. Add a gym first.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + text = "No gyms available. Add a gym first.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error ) } else { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(gyms) { gym -> FilterChip( - onClick = { selectedGym = gym }, - label = { Text(gym.name) }, - selected = selectedGym?.id == gym.id + onClick = { selectedGym = gym }, + label = { Text(gym.name) }, + selected = selectedGym?.id == gym.id ) } } @@ -801,50 +815,47 @@ fun AddEditSessionScreen( } } } - + // Session Details item { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Session Details", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Session Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(16.dp)) - + OutlinedTextField( - value = sessionDate, - onValueChange = { sessionDate = it }, - label = { Text("Date (YYYY-MM-DD)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true + value = sessionDate, + onValueChange = { sessionDate = it }, + label = { Text("Date (YYYY-MM-DD)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true ) - + Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( - value = duration, - onValueChange = { duration = it }, - label = { Text("Duration (minutes)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + value = duration, + onValueChange = { duration = it }, + label = { Text("Duration (minutes)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Number) ) - + Spacer(modifier = Modifier.height(8.dp)) - + OutlinedTextField( - value = sessionNotes, - onValueChange = { sessionNotes = it }, - label = { Text("Session Notes (Optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 + value = sessionNotes, + onValueChange = { sessionNotes = it }, + label = { Text("Session Notes (Optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 ) } } @@ -852,6 +863,3 @@ fun AddEditSessionScreen( } } } - - - diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt index 2e40d07..2a5ac9b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/DetailScreens.kt @@ -693,15 +693,6 @@ fun ProblemDetailScreen( } } - problem?.setter?.let { setter -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Set by: $setter", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (problem?.tags?.isNotEmpty() == true) { Spacer(modifier = Modifier.height(12.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt index 80084e1..d7de626 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt @@ -21,181 +21,181 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProblemsScreen( - viewModel: ClimbViewModel, - onNavigateToProblemDetail: (String) -> Unit -) { +fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) { val problems by viewModel.problems.collectAsState() val gyms by viewModel.gyms.collectAsState() var showImageViewer by remember { mutableStateOf(false) } var selectedImagePaths by remember { mutableStateOf>(emptyList()) } var selectedImageIndex by remember { mutableIntStateOf(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 } != false - val gymMatch = selectedGym?.let { it.id == problem.gymId } != false - climbTypeMatch && gymMatch - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { + val filteredProblems = + problems.filter { problem -> + val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false + val gymMatch = selectedGym?.let { it.id == problem.gymId } != false + climbTypeMatch && gymMatch + } + + // Separate active and inactive problems + val activeProblems = filteredProblems.filter { it.isActive } + val inactiveProblems = filteredProblems.filter { !it.isActive } + val sortedProblems = activeProblems + inactiveProblems + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_mountains), - contentDescription = "OpenClimb Logo", - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + painter = painterResource(id = R.drawable.ic_mountains), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary ) Text( - text = "Problems & Routes", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Problems & Routes", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) } - + Spacer(modifier = Modifier.height(16.dp)) - + // Filters Section if (problems.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Filters", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + 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 + text = "Climb Type", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - + Spacer(modifier = Modifier.height(8.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip( - onClick = { selectedClimbType = null }, - label = { Text("All Types") }, - selected = selectedClimbType == null + 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 + 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 + text = "Gym", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - + Spacer(modifier = Modifier.height(8.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip( - onClick = { selectedGym = null }, - label = { Text("All Gyms") }, - selected = selectedGym == null + 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 + 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 + text = + "Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - + Spacer(modifier = Modifier.height(16.dp)) } - + if (filteredProblems.isEmpty()) { EmptyStateMessage( - 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 = "" + 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(filteredProblems) { problem -> + items(sortedProblems) { problem -> ProblemCard( - problem = problem, - gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", - onClick = { onNavigateToProblemDetail(problem.id) }, - onImageClick = { imagePaths, index -> - selectedImagePaths = imagePaths - selectedImageIndex = index - showImageViewer = true - } + problem = problem, + gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", + onClick = { onNavigateToProblemDetail(problem.id) }, + onImageClick = { imagePaths, index -> + selectedImagePaths = imagePaths + selectedImageIndex = index + showImageViewer = true + }, + onToggleActive = { + val updatedProblem = problem.copy(isActive = !problem.isActive) + viewModel.updateProblem(updatedProblem) + } ) Spacer(modifier = Modifier.height(8.dp)) } } } } - + // Fullscreen Image Viewer if (showImageViewer && selectedImagePaths.isNotEmpty()) { FullscreenImageViewer( - imagePaths = selectedImagePaths, - initialIndex = selectedImageIndex, - onDismiss = { showImageViewer = false } + imagePaths = selectedImagePaths, + initialIndex = selectedImageIndex, + onDismiss = { showImageViewer = false } ) } } @@ -203,97 +203,117 @@ fun ProblemsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProblemCard( - problem: Problem, - gymName: String, - onClick: () -> Unit, - onImageClick: ((List, Int) -> Unit)? = null + problem: Problem, + gymName: String, + onClick: () -> Unit, + onImageClick: ((List, Int) -> Unit)? = null, + onToggleActive: (() -> Unit)? = null ) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top ) { Column(modifier = Modifier.weight(1f)) { Text( - text = problem.name ?: "Unnamed Problem", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = problem.name ?: "Unnamed Problem", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (problem.isActive) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) - + Text( - text = gymName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = gymName, + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (problem.isActive) 1f else 0.6f + ) ) } - + Column(horizontalAlignment = Alignment.End) { Text( - text = problem.difficulty.grade, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + text = problem.difficulty.grade, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) - + Text( - text = problem.climbType.getDisplayName(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = problem.climbType.getDisplayName(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - + problem.location?.let { location -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Location: $location", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Location: $location", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - + if (problem.tags.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Row { problem.tags.take(3).forEach { tag -> AssistChip( - onClick = { }, - label = { Text(tag) }, - modifier = Modifier.padding(end = 4.dp) + onClick = {}, + label = { Text(tag) }, + modifier = Modifier.padding(end = 4.dp) ) } } } - + // Display images if any if (problem.imagePaths.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) ImageDisplay( - imagePaths = problem.imagePaths.take(3), // Show max 3 images in list - imageSize = 60, - onImageClick = { index -> - onImageClick?.invoke(problem.imagePaths, index) - } + imagePaths = problem.imagePaths.take(3), // Show max 3 images in list + imageSize = 60, + onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) } ) } - + if (!problem.isActive) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Inactive", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + text = "Reset / No Longer Set", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Medium ) } + + // Toggle active button + if (onToggleActive != null) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onToggleActive, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = + if (problem.isActive) + MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (problem.isActive) "Mark as Reset" else "Mark as Active", + style = MaterialTheme.typography.bodySmall + ) + } + } } } } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt index ad32149..fb6748e 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt @@ -5,139 +5,146 @@ import android.content.Intent import android.graphics.* import android.graphics.drawable.GradientDrawable import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import androidx.core.graphics.toColorInt import com.atridad.openclimb.data.model.* import java.io.File import java.io.FileOutputStream import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.math.roundToInt -import androidx.core.graphics.createBitmap -import androidx.core.graphics.toColorInt object SessionShareUtils { - + data class SessionStats( - val totalAttempts: Int, - val successfulAttempts: Int, - val problems: List, - val uniqueProblemsAttempted: Int, - val uniqueProblemsCompleted: Int, - val averageGrade: String?, - val sessionDuration: String, - val topResult: AttemptResult?, - val topGrade: String? + val totalAttempts: Int, + val successfulAttempts: Int, + val problems: List, + val uniqueProblemsAttempted: Int, + val uniqueProblemsCompleted: Int, + val averageGrade: String?, + val sessionDuration: String, + val topResult: AttemptResult?, + val topGrade: String? ) - + fun calculateSessionStats( - session: ClimbSession, - attempts: List, - problems: List + session: ClimbSession, + attempts: List, + problems: List ): SessionStats { - val successfulResults = listOf( - AttemptResult.SUCCESS, - AttemptResult.FLASH - ) - + val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) + val successfulAttempts = attempts.filter { it.result in successfulResults } val uniqueProblems = attempts.map { it.problemId }.distinct() val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() - + val attemptedProblems = problems.filter { it.id in uniqueProblems } - + // Calculate separate averages for different climbing types and difficulty systems val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } - + val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder") val ropeAverage = calculateAverageGrade(ropeProblems, "Rope") - + // Combine averages for display - val averageGrade = when { - boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage" - boulderAverage != null -> boulderAverage - ropeAverage != null -> ropeAverage - else -> null - } - + val averageGrade = + when { + boulderAverage != null && ropeAverage != null -> + "$boulderAverage / $ropeAverage" + boulderAverage != null -> boulderAverage + ropeAverage != null -> ropeAverage + else -> null + } + // Determine highest achieved grade (only from completed problems: SUCCESS or FLASH) val completedProblems = problems.filter { it.id in uniqueCompletedProblems } val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER } val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE } val topBoulder = highestGradeForProblems(completedBoulder) val topRope = highestGradeForProblems(completedRope) - val topGrade = when { - topBoulder != null && topRope != null -> "$topBoulder / $topRope" - topBoulder != null -> topBoulder - topRope != null -> topRope - else -> null - } - + val topGrade = + when { + topBoulder != null && topRope != null -> "$topBoulder / $topRope" + topBoulder != null -> topBoulder + topRope != null -> topRope + else -> null + } + val duration = if (session.duration != null) "${session.duration}m" else "Unknown" - val topResult = attempts.maxByOrNull { - when (it.result) { - AttemptResult.FLASH -> 3 - AttemptResult.SUCCESS -> 2 - AttemptResult.FALL -> 1 - else -> 0 - } - }?.result - + val topResult = + attempts + .maxByOrNull { + when (it.result) { + AttemptResult.FLASH -> 3 + AttemptResult.SUCCESS -> 2 + AttemptResult.FALL -> 1 + else -> 0 + } + } + ?.result + return SessionStats( - totalAttempts = attempts.size, - successfulAttempts = successfulAttempts.size, - problems = attemptedProblems, - uniqueProblemsAttempted = uniqueProblems.size, - uniqueProblemsCompleted = uniqueCompletedProblems.size, - averageGrade = averageGrade, - sessionDuration = duration, - topResult = topResult, - topGrade = topGrade + totalAttempts = attempts.size, + successfulAttempts = successfulAttempts.size, + problems = attemptedProblems, + uniqueProblemsAttempted = uniqueProblems.size, + uniqueProblemsCompleted = uniqueCompletedProblems.size, + averageGrade = averageGrade, + sessionDuration = duration, + topResult = topResult, + topGrade = topGrade ) } - + /** * Calculate average grade for a specific set of problems, respecting their difficulty systems */ private fun calculateAverageGrade(problems: List, climbingType: String): String? { if (problems.isEmpty()) return null - + // Group problems by difficulty system val problemsBySystem = problems.groupBy { it.difficulty.system } - + val averages = mutableListOf() - + problemsBySystem.forEach { (system, systemProblems) -> when (system) { DifficultySystem.V_SCALE -> { - val gradeValues = systemProblems.mapNotNull { problem -> - when { - problem.difficulty.grade == "VB" -> 0 - else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() - } - } + val gradeValues = + systemProblems.mapNotNull { problem -> + when { + problem.difficulty.grade == "VB" -> 0 + else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() + } + } if (gradeValues.isNotEmpty()) { val avg = gradeValues.average().roundToInt() averages.add(if (avg == 0) "VB" else "V$avg") } } DifficultySystem.FONT -> { - val gradeValues = systemProblems.mapNotNull { problem -> - // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7) - problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() - } + val gradeValues = + systemProblems.mapNotNull { problem -> + // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> + // 7) + problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() + } if (gradeValues.isNotEmpty()) { val avg = gradeValues.average().roundToInt() averages.add("$avg") } } DifficultySystem.YDS -> { - val gradeValues = systemProblems.mapNotNull { problem -> - // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) - val grade = problem.difficulty.grade - if (grade.startsWith("5.")) { - grade.substring(2).toDoubleOrNull() - } else null - } + val gradeValues = + systemProblems.mapNotNull { problem -> + // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) + val grade = problem.difficulty.grade + if (grade.startsWith("5.")) { + grade.substring(2).toDoubleOrNull() + } else null + } if (gradeValues.isNotEmpty()) { val avg = gradeValues.average() averages.add("5.${String.format("%.1f", avg)}") @@ -145,9 +152,13 @@ object SessionShareUtils { } DifficultySystem.CUSTOM -> { // For custom systems, try to extract numeric values - val gradeValues = systemProblems.mapNotNull { problem -> - problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() - } + val gradeValues = + systemProblems.mapNotNull { problem -> + problem.difficulty + .grade + .filter { it.isDigit() || it == '.' || it == '-' } + .toDoubleOrNull() + } if (gradeValues.isNotEmpty()) { val avg = gradeValues.average() averages.add(String.format("%.1f", avg)) @@ -155,7 +166,7 @@ object SessionShareUtils { } } } - + return if (averages.isNotEmpty()) { if (averages.size == 1) { averages.first() @@ -166,185 +177,262 @@ object SessionShareUtils { } fun generateShareCard( - context: Context, - session: ClimbSession, - gym: Gym, - stats: SessionStats + context: Context, + session: ClimbSession, + gym: Gym, + stats: SessionStats ): File? { return try { val width = 1242 // 3:4 aspect at higher resolution for better fit val height = 1656 - + val bitmap = createBitmap(width, height) val canvas = Canvas(bitmap) - - val gradientDrawable = GradientDrawable( - GradientDrawable.Orientation.TOP_BOTTOM, - intArrayOf( - "#667eea".toColorInt(), - "#764ba2".toColorInt() - ) - ) + + val gradientDrawable = + GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt()) + ) gradientDrawable.setBounds(0, 0, width, height) gradientDrawable.draw(canvas) - + // Setup paint objects - val titlePaint = Paint().apply { - color = Color.WHITE - textSize = 72f - typeface = Typeface.DEFAULT_BOLD - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val subtitlePaint = Paint().apply { - color = "#E8E8E8".toColorInt() - textSize = 48f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val statLabelPaint = Paint().apply { - color = "#B8B8B8".toColorInt() - textSize = 36f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val statValuePaint = Paint().apply { - color = Color.WHITE - textSize = 64f - typeface = Typeface.DEFAULT_BOLD - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - val cardPaint = Paint().apply { - color = "#40FFFFFF".toColorInt() - isAntiAlias = true - } - + val titlePaint = + Paint().apply { + color = Color.WHITE + textSize = 72f + typeface = Typeface.DEFAULT_BOLD + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val subtitlePaint = + Paint().apply { + color = "#E8E8E8".toColorInt() + textSize = 48f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val statLabelPaint = + Paint().apply { + color = "#B8B8B8".toColorInt() + textSize = 36f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val statValuePaint = + Paint().apply { + color = Color.WHITE + textSize = 64f + typeface = Typeface.DEFAULT_BOLD + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + val cardPaint = + Paint().apply { + color = "#40FFFFFF".toColorInt() + isAntiAlias = true + } + // Draw main card background val cardRect = RectF(60f, 200f, width - 60f, height - 120f) canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) - + // Draw content var yPosition = 300f - + // Title canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint) yPosition += 80f - + // Gym and date canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint) yPosition += 60f - + val dateText = formatSessionDate(session.date) canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint) yPosition += 120f - + // Stats grid val statsStartY = yPosition val columnWidth = width / 2f val columnMaxTextWidth = columnWidth - 120f - + // Left column stats var leftY = statsStartY - drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) + drawStatItemFitting( + canvas, + columnWidth / 2f, + leftY, + "Attempts", + stats.totalAttempts.toString(), + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) leftY += 120f - drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) + drawStatItemFitting( + canvas, + columnWidth / 2f, + leftY, + "Problems", + stats.uniqueProblemsAttempted.toString(), + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) leftY += 120f - drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth) - + drawStatItemFitting( + canvas, + columnWidth / 2f, + leftY, + "Duration", + stats.sessionDuration, + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) + // Right column stats var rightY = statsStartY - drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) + drawStatItemFitting( + canvas, + width - columnWidth / 2f, + rightY, + "Completed", + stats.uniqueProblemsCompleted.toString(), + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) rightY += 120f - drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) - rightY += 120f - + var rightYAfter = rightY stats.topGrade?.let { grade -> - drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth) + drawStatItemFitting( + canvas, + width - columnWidth / 2f, + rightY, + "Top Grade", + grade, + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) rightYAfter += 120f } - + // Grade range(s) - val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER }) - val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE }) + val boulderRange = + gradeRangeForProblems( + stats.problems.filter { it.climbType == ClimbType.BOULDER } + ) + val ropeRange = + gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE }) val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f if (boulderRange != null && ropeRange != null) { // Two evenly spaced items - drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth) - drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth) + drawStatItemFitting( + canvas, + columnWidth / 2f, + rangesY, + "Boulder Range", + boulderRange, + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) + drawStatItemFitting( + canvas, + width - columnWidth / 2f, + rangesY, + "Rope Range", + ropeRange, + statLabelPaint, + statValuePaint, + columnMaxTextWidth + ) } else if (boulderRange != null || ropeRange != null) { // Single centered item val singleRange = boulderRange ?: ropeRange ?: "" - drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f) + drawStatItemFitting( + canvas, + width / 2f, + rangesY, + "Grade Range", + singleRange, + statLabelPaint, + statValuePaint, + width - 200f + ) } - - // App branding - val brandingPaint = Paint().apply { - color = "#80FFFFFF".toColorInt() - textSize = 32f - typeface = Typeface.DEFAULT - isAntiAlias = true - textAlign = Paint.Align.CENTER - } + val brandingPaint = + Paint().apply { + color = "#80FFFFFF".toColorInt() + textSize = 32f + typeface = Typeface.DEFAULT + isAntiAlias = true + textAlign = Paint.Align.CENTER + } canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint) - + // Save to file val shareDir = File(context.cacheDir, "shares") if (!shareDir.exists()) { shareDir.mkdirs() } - + val filename = "session_${session.id}_${System.currentTimeMillis()}.png" val file = File(shareDir, filename) - + val outputStream = FileOutputStream(file) bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) outputStream.flush() outputStream.close() - + bitmap.recycle() - + file } catch (e: Exception) { e.printStackTrace() null } } - + private fun drawStatItem( - canvas: Canvas, - x: Float, - y: Float, - label: String, - value: String, - labelPaint: Paint, - valuePaint: Paint + canvas: Canvas, + x: Float, + y: Float, + label: String, + value: String, + labelPaint: Paint, + valuePaint: Paint ) { canvas.drawText(value, x, y, valuePaint) canvas.drawText(label, x, y + 50f, labelPaint) } - + /** - * Draws a stat item while fitting the value text to a max width by reducing text size if needed. + * Draws a stat item while fitting the value text to a max width by reducing text size if + * needed. */ private fun drawStatItemFitting( - canvas: Canvas, - x: Float, - y: Float, - label: String, - value: String, - labelPaint: Paint, - valuePaint: Paint, - maxTextWidth: Float + canvas: Canvas, + x: Float, + y: Float, + label: String, + value: String, + labelPaint: Paint, + valuePaint: Paint, + maxTextWidth: Float ) { val tempPaint = Paint(valuePaint) var textSize = tempPaint.textSize @@ -357,7 +445,7 @@ object SessionShareUtils { canvas.drawText(value, x, y, tempPaint) canvas.drawText(label, x, y + 50f, labelPaint) } - + /** * Returns a range string like "X - Y" for the given problems, based on their difficulty grades. */ @@ -367,9 +455,7 @@ object SessionShareUtils { val sorted = grades.sortedWith { a, b -> a.compareTo(b) } return "${sorted.first().grade} - ${sorted.last().grade}" } - - private fun formatSessionDate(dateString: String): String { return try { val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME @@ -380,23 +466,28 @@ object SessionShareUtils { dateString.take(10) } } - + fun shareSessionCard(context: Context, imageFile: File) { try { - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - imageFile - ) - - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! 🧗‍♀️ #OpenClimb") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile + ) + + val shareIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra( + Intent.EXTRA_TEXT, + "Check out my climbing session! 🧗‍♀️ #OpenClimb" + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(shareIntent, "Share Session") chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(chooser) @@ -406,16 +497,18 @@ object SessionShareUtils { } /** - * Returns the highest grade string among the given problems, respecting their difficulty system. + * Returns the highest grade string among the given problems, respecting their difficulty + * system. */ private fun highestGradeForProblems(problems: List): String? { if (problems.isEmpty()) return null - return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade + return problems + .maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) } + ?.difficulty + ?.grade } - /** - * Produces a comparable numeric rank for grades across supported systems. - */ + /** Produces a comparable numeric rank for grades across supported systems. */ private fun gradeRank(system: DifficultySystem, grade: String): Double { return when (system) { DifficultySystem.V_SCALE -> { @@ -424,7 +517,8 @@ object SessionShareUtils { DifficultySystem.FONT -> { val list = DifficultySystem.FONT.getAvailableGrades() val idx = list.indexOf(grade.uppercase()) - if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 + if (idx >= 0) idx.toDouble() + else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 } DifficultySystem.YDS -> { // Parse 5.X with optional letter a-d @@ -434,13 +528,14 @@ object SessionShareUtils { val numberPart = tail.takeWhile { it.isDigit() || it == '.' } val letterPart = tail.drop(numberPart.length).firstOrNull() val base = numberPart.toDoubleOrNull() ?: return -1.0 - val letterWeight = when (letterPart) { - 'a' -> 0.0 - 'b' -> 0.1 - 'c' -> 0.2 - 'd' -> 0.3 - else -> 0.0 - } + val letterWeight = + when (letterPart) { + 'a' -> 0.0 + 'b' -> 0.1 + 'c' -> 0.2 + 'd' -> 0.3 + else -> 0.0 + } base + letterWeight } DifficultySystem.CUSTOM -> { diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index d01e414..f8e7793 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -394,7 +394,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -437,7 +437,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -479,7 +479,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -509,7 +509,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 10fa4a8..6acfc54 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme new file mode 100644 index 0000000..e69de29 diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/SessionStatusLiveExtension.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/SessionStatusLiveExtension.xcscheme new file mode 100644 index 0000000..770352d --- /dev/null +++ b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/SessionStatusLiveExtension.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist index 53d0216..7dfcbe0 100644 --- a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ OpenClimb.xcscheme_^#shared#^_ orderHint - 0 + 1 SessionStatusLiveExtension.xcscheme_^#shared#^_ @@ -15,5 +15,18 @@ 0 + SuppressBuildableAutocreation + + D24C19672E75002A0045894C + + primary + + + D2FE948A2E78FEE0008CDB25 + + primary + + + diff --git a/ios/OpenClimb/Models/DataModels.swift b/ios/OpenClimb/Models/DataModels.swift index c395279..a514538 100644 --- a/ios/OpenClimb/Models/DataModels.swift +++ b/ios/OpenClimb/Models/DataModels.swift @@ -260,7 +260,7 @@ struct Problem: Identifiable, Codable, Hashable { let description: String? let climbType: ClimbType let difficulty: DifficultyGrade - let setter: String? + let tags: [String] let location: String? let imagePaths: [String] @@ -272,7 +272,7 @@ struct Problem: Identifiable, Codable, Hashable { init( gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType, - difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [], + difficulty: DifficultyGrade, tags: [String] = [], location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil, notes: String? = nil ) { @@ -282,7 +282,7 @@ struct Problem: Identifiable, Codable, Hashable { self.description = description self.climbType = climbType self.difficulty = difficulty - self.setter = setter + self.tags = tags self.location = location self.imagePaths = imagePaths @@ -296,7 +296,7 @@ struct Problem: Identifiable, Codable, Hashable { func updated( name: String? = nil, description: String? = nil, climbType: ClimbType? = nil, - difficulty: DifficultyGrade? = nil, setter: String? = nil, tags: [String]? = nil, + difficulty: DifficultyGrade? = nil, tags: [String]? = nil, location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil, dateSet: Date? = nil, notes: String? = nil ) -> Problem { @@ -307,7 +307,7 @@ struct Problem: Identifiable, Codable, Hashable { description: description ?? self.description, climbType: climbType ?? self.climbType, difficulty: difficulty ?? self.difficulty, - setter: setter ?? self.setter, + tags: tags ?? self.tags, location: location ?? self.location, imagePaths: imagePaths ?? self.imagePaths, @@ -321,7 +321,7 @@ struct Problem: Identifiable, Codable, Hashable { private init( id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, + difficulty: DifficultyGrade, tags: [String], location: String?, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, updatedAt: Date ) { @@ -331,7 +331,7 @@ struct Problem: Identifiable, Codable, Hashable { self.description = description self.climbType = climbType self.difficulty = difficulty - self.setter = setter + self.tags = tags self.location = location self.imagePaths = imagePaths @@ -344,7 +344,7 @@ struct Problem: Identifiable, Codable, Hashable { static func fromImport( id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, + difficulty: DifficultyGrade, tags: [String], location: String?, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, updatedAt: Date ) -> Problem { @@ -355,7 +355,7 @@ struct Problem: Identifiable, Codable, Hashable { description: description, climbType: climbType, difficulty: difficulty, - setter: setter, + tags: tags, location: location, imagePaths: imagePaths, diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index cbd55fa..112f549 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -475,7 +475,7 @@ class ClimbingDataManager: ObservableObject { let exportData = ClimbDataExport( exportedAt: dateFormatter.string(from: Date()), - version: "1.0", + version: "2.0", gyms: gyms.map { AndroidGym(from: $0) }, problems: problems.map { AndroidProblem(from: $0) }, sessions: sessions.map { AndroidClimbSession(from: $0) }, @@ -593,7 +593,7 @@ struct ClimbDataExport: Codable { let attempts: [AndroidAttempt] init( - exportedAt: String, version: String = "1.0", gyms: [AndroidGym], problems: [AndroidProblem], + exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem], sessions: [AndroidClimbSession], attempts: [AndroidAttempt] ) { self.exportedAt = exportedAt @@ -675,7 +675,6 @@ struct AndroidProblem: Codable { let description: String? let climbType: ClimbType let difficulty: DifficultyGrade - let setter: String? let tags: [String] let location: String? let imagePaths: [String]? @@ -692,7 +691,6 @@ struct AndroidProblem: Codable { self.description = problem.description self.climbType = problem.climbType self.difficulty = problem.difficulty - self.setter = problem.setter self.tags = problem.tags self.location = problem.location self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths @@ -707,7 +705,7 @@ struct AndroidProblem: Codable { init( id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [], + difficulty: DifficultyGrade, tags: [String] = [], location: String? = nil, imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil, notes: String? = nil, @@ -719,7 +717,6 @@ struct AndroidProblem: Codable { self.description = description self.climbType = climbType self.difficulty = difficulty - self.setter = setter self.tags = tags self.location = location self.imagePaths = imagePaths @@ -746,7 +743,6 @@ struct AndroidProblem: Codable { description: description, climbType: climbType, difficulty: difficulty, - setter: setter, tags: tags, location: location, imagePaths: imagePaths ?? [], @@ -766,7 +762,6 @@ struct AndroidProblem: Codable { description: self.description, climbType: self.climbType, difficulty: self.difficulty, - setter: self.setter, tags: self.tags, location: self.location, imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, @@ -1331,7 +1326,6 @@ extension ClimbingDataManager { description: "Technical overhang with small holds", climbType: .boulder, difficulty: DifficultyGrade(system: .vScale, grade: "V4"), - setter: "John Doe", tags: ["technical", "overhang"], location: "Cave area" ) diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 336b58f..3c3afae 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -635,12 +635,6 @@ struct ProblemExpandedView: View { .foregroundColor(.secondary) } - if let setter = problem.setter, !setter.isEmpty { - Label(setter, systemImage: "person") - .font(.subheadline) - .foregroundColor(.secondary) - } - if let description = problem.description, !description.isEmpty { Text(description) .font(.body) diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 811f64e..78e91ec 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -13,7 +13,6 @@ struct AddEditProblemView: View { @State private var selectedClimbType: ClimbType = .boulder @State private var selectedDifficultySystem: DifficultySystem = .vScale @State private var difficultyGrade = "" - @State private var setter = "" @State private var location = "" @State private var tags = "" @State private var notes = "" @@ -63,7 +62,7 @@ struct AddEditProblemView: View { PhotosSection() ClimbTypeSection() DifficultySection() - LocationAndSetterSection() + LocationSection() TagsSection() AdditionalInfoSection() } @@ -158,7 +157,6 @@ struct AddEditProblemView: View { ) } - TextField("Route Setter (Optional)", text: $setter) } } @@ -281,7 +279,7 @@ struct AddEditProblemView: View { } @ViewBuilder - private func LocationAndSetterSection() -> some View { + private func LocationSection() -> some View { Section("Location & Details") { TextField( "Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'")) @@ -334,25 +332,28 @@ struct AddEditProblemView: View { HStack(spacing: 12) { ForEach(imageData.indices, id: \.self) { index in if let uiImage = UIImage(data: imageData[index]) { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 80, height: 80) - .clipped() - .cornerRadius(8) - .overlay(alignment: .topTrailing) { - Button(action: { - imageData.remove(at: index) - if index < imagePaths.count { - imagePaths.remove(at: index) - } - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .background(Circle().fill(.white)) + ZStack(alignment: .topTrailing) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipped() + .cornerRadius(8) + + Button(action: { + imageData.remove(at: index) + if index < imagePaths.count { + imagePaths.remove(at: index) } - .offset(x: 8, y: -8) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .background(Circle().fill(.white)) + .font(.system(size: 18)) } + .offset(x: 4, y: -4) + } + .frame(width: 88, height: 88) // Extra space for button } else { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.3)) @@ -365,6 +366,7 @@ struct AddEditProblemView: View { } } .padding(.horizontal, 1) + .padding(.vertical, 8) } } } @@ -410,7 +412,7 @@ struct AddEditProblemView: View { selectedClimbType = problem.climbType selectedDifficultySystem = problem.difficulty.system difficultyGrade = problem.difficulty.grade - setter = problem.setter ?? "" + location = problem.location ?? "" tags = problem.tags.joined(separator: ", ") notes = problem.notes ?? "" @@ -420,7 +422,7 @@ struct AddEditProblemView: View { // Load image data for preview imageData = [] for imagePath in problem.imagePaths { - if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) { + if let data = ImageManager.shared.loadImageData(fromPath: imagePath) { imageData.append(data) } } @@ -479,7 +481,7 @@ struct AddEditProblemView: View { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTags = tags.split(separator: ",").map { @@ -494,7 +496,7 @@ struct AddEditProblemView: View { description: trimmedDescription.isEmpty ? nil : trimmedDescription, climbType: selectedClimbType, difficulty: difficulty, - setter: trimmedSetter.isEmpty ? nil : trimmedSetter, + tags: trimmedTags, location: trimmedLocation.isEmpty ? nil : trimmedLocation, imagePaths: imagePaths, @@ -510,7 +512,7 @@ struct AddEditProblemView: View { description: trimmedDescription.isEmpty ? nil : trimmedDescription, climbType: selectedClimbType, difficulty: difficulty, - setter: trimmedSetter.isEmpty ? nil : trimmedSetter, + tags: trimmedTags, location: trimmedLocation.isEmpty ? nil : trimmedLocation, imagePaths: imagePaths, diff --git a/ios/OpenClimb/Views/Detail/GymDetailView.swift b/ios/OpenClimb/Views/Detail/GymDetailView.swift index 3b6e57f..dd86fe7 100644 --- a/ios/OpenClimb/Views/Detail/GymDetailView.swift +++ b/ios/OpenClimb/Views/Detail/GymDetailView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct GymDetailView: View { @@ -60,8 +59,10 @@ struct GymDetailView: View { ToolbarItemGroup(placement: .navigationBarTrailing) { if gym != nil { Menu { - Button("Edit Gym") { + Button { // Navigate to edit view + } label: { + Label("Edit Gym", systemImage: "pencil") } Button(role: .destructive) { diff --git a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift index 62dc277..fe38fa1 100644 --- a/ios/OpenClimb/Views/Detail/ProblemDetailView.swift +++ b/ios/OpenClimb/Views/Detail/ProblemDetailView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct ProblemDetailView: View { @@ -64,8 +63,10 @@ struct ProblemDetailView: View { ToolbarItemGroup(placement: .navigationBarTrailing) { if problem != nil { Menu { - Button("Edit Problem") { + Button { showingEditProblem = true + } label: { + Label("Edit Problem", systemImage: "pencil") } Button(role: .destructive) { @@ -167,12 +168,6 @@ struct ProblemHeaderCard: View { .font(.body) } - if let setter = problem.setter, !setter.isEmpty { - Text("Set by: \(setter)") - .font(.subheadline) - .foregroundColor(.secondary) - } - if !problem.tags.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { diff --git a/ios/OpenClimb/Views/Detail/SessionDetailView.swift b/ios/OpenClimb/Views/Detail/SessionDetailView.swift index b4ad000..84a8e4d 100644 --- a/ios/OpenClimb/Views/Detail/SessionDetailView.swift +++ b/ios/OpenClimb/Views/Detail/SessionDetailView.swift @@ -280,7 +280,6 @@ struct SessionStatsCard: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)") StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)") - StatItem(label: "Successful", value: "\(stats.successfulAttempts)") StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)") } } diff --git a/ios/OpenClimb/Views/GymsView.swift b/ios/OpenClimb/Views/GymsView.swift index 078dd4f..31c895a 100644 --- a/ios/OpenClimb/Views/GymsView.swift +++ b/ios/OpenClimb/Views/GymsView.swift @@ -1,4 +1,3 @@ - import SwiftUI struct GymsView: View { @@ -49,7 +48,10 @@ struct GymsList: View { Button { gymToEdit = gym } label: { - Label("Edit", systemImage: "pencil") + HStack { + Image(systemName: "pencil") + Text("Edit") + } } .tint(.blue) } diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index 36b3fd2..da4bd6b 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -13,10 +13,9 @@ struct ProblemsView: View { // Apply search filter if !searchText.isEmpty { filtered = filtered.filter { problem in - (problem.name?.localizedCaseInsensitiveContains(searchText) ?? false) + return problem.name?.localizedCaseInsensitiveContains(searchText) ?? false || (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false) || (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false) - || (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false) || problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) } } } @@ -31,7 +30,11 @@ struct ProblemsView: View { filtered = filtered.filter { $0.gymId == gym.id } } - return filtered.sorted { $0.updatedAt > $1.updatedAt } + // Separate active and inactive problems + let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt } + let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt } + + return active + inactive } var body: some View { @@ -195,10 +198,23 @@ struct ProblemsList: View { Label("Delete", systemImage: "trash") } + Button { + let updatedProblem = problem.updated(isActive: !problem.isActive) + dataManager.updateProblem(updatedProblem) + } label: { + Label( + problem.isActive ? "Mark as Reset" : "Mark as Active", + systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle") + } + .tint(.orange) + Button { problemToEdit = problem } label: { - Label("Edit", systemImage: "pencil") + HStack { + Image(systemName: "pencil") + Text("Edit") + } } .tint(.blue) } @@ -239,6 +255,7 @@ struct ProblemRow: View { Text(problem.name ?? "Unnamed Problem") .font(.headline) .fontWeight(.semibold) + .foregroundColor(problem.isActive ? .primary : .secondary) Text(gym?.name ?? "Unknown Gym") .font(.subheadline) @@ -295,9 +312,9 @@ struct ProblemRow: View { } if !problem.isActive { - Text("Inactive") + Text("Reset / No Longer Set") .font(.caption) - .foregroundColor(.red) + .foregroundColor(.orange) .fontWeight(.medium) } }