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