From 1aeded35d15e7e8a72667a74438f24efb644038b Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 22 Aug 2025 23:22:23 -0600 Subject: [PATCH] 1.1.2 - More fixes for notification reliability --- .idea/deploymentTargetSelector.xml | 8 + app/build.gradle.kts | 20 +- app/src/main/AndroidManifest.xml | 3 + .../openclimb/ui/components/LineChart.kt | 297 ++++++++++++++ .../openclimb/ui/screens/AddEditScreens.kt | 21 +- .../openclimb/ui/screens/AnalyticsScreen.kt | 367 ++++++++++++++++-- .../openclimb/ui/screens/DetailScreens.kt | 31 +- .../openclimb/utils/SessionShareUtils.kt | 51 +-- gradle/libs.versions.toml | 31 +- 9 files changed, 679 insertions(+), 150 deletions(-) create mode 100644 app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..e0d73ba 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6562bf0..a59006e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -12,10 +14,10 @@ android { defaultConfig { applicationId = "com.atridad.openclimb" - minSdk = 33 + minSdk = 34 targetSdk = 36 - versionCode = 16 - versionName = "1.1.1" + versionCode = 18 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -33,9 +35,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } java { toolchain { @@ -48,6 +47,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + dependencies { // Core Android libraries implementation(libs.androidx.core.ktx) @@ -64,6 +69,7 @@ dependencies { // Room Database implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) // Navigation @@ -81,6 +87,8 @@ dependencies { // Image Loading implementation(libs.coil.compose) + + // Testing testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5ee4fe..d14e7ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ android:maxSdkVersion="28" /> + + + diff --git a/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt b/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt new file mode 100644 index 0000000..3f5809c --- /dev/null +++ b/app/src/main/java/com/atridad/openclimb/ui/components/LineChart.kt @@ -0,0 +1,297 @@ +package com.atridad.openclimb.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Data point for the line chart + */ +data class ChartDataPoint( + val x: Float, + val y: Float, + val label: String? = null +) + +/** + * Configuration for chart styling + */ +data class ChartStyle( + val lineColor: Color = Color(0xFF6366F1), + val fillColor: Color = Color(0x336366F1), + val lineWidth: Float = 3f, + val gridColor: Color = Color(0xFFE5E7EB), + val textColor: Color = Color(0xFF374151), + val backgroundColor: Color = Color.White +) + +/** + * Custom Line Chart with area fill below the line + */ +@Composable +fun LineChart( + data: List, + modifier: Modifier = Modifier, + style: ChartStyle = ChartStyle(), + showGrid: Boolean = true, + xAxisFormatter: (Float) -> String = { it.toString() }, + yAxisFormatter: (Float) -> String = { it.toString() } +) { + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + + Box(modifier = modifier) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + if (data.isEmpty()) return@Canvas + + val padding = with(density) { 32.dp.toPx() } + val chartWidth = size.width - padding * 2 + val chartHeight = size.height - padding * 2 + + // Calculate data bounds + val dataMinY = data.minOf { it.y } + val dataMaxY = data.maxOf { it.y } + + // Add some padding to Y-axis (10% above and below the data range) + val yPadding = if (dataMaxY == dataMinY) 1f else (dataMaxY - dataMinY) * 0.1f + val minY = dataMinY - yPadding + val maxY = dataMaxY + yPadding + + val minX = data.minOf { it.x } + val maxX = data.maxOf { it.x } + + val xRange = if (maxX - minX == 0f) 1f else maxX - minX // Minimum range of 1 for single points + val yRange = maxY - minY + + // Ensure we have valid ranges + if (yRange == 0f) return@Canvas + + // Convert data points to screen coordinates + val screenPoints = data.map { point -> + val x = padding + (point.x - minX) / xRange * chartWidth + val y = padding + chartHeight - (point.y - minY) / yRange * chartHeight + Offset(x, y) + } + + // Draw background + drawRect( + color = style.backgroundColor, + topLeft = Offset(padding, padding), + size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight) + ) + + // Draw grid + if (showGrid) { + drawGrid( + padding = padding, + chartWidth = chartWidth, + chartHeight = chartHeight, + gridColor = style.gridColor, + minX = minX, + maxX = maxX, + minY = minY, + maxY = maxY, + textMeasurer = textMeasurer, + textColor = style.textColor, + xAxisFormatter = xAxisFormatter, + yAxisFormatter = yAxisFormatter, + actualDataPoints = data + ) + } + + // Draw area fill + if (screenPoints.size > 1) { + drawAreaFill( + points = screenPoints, + padding = padding, + chartHeight = chartHeight, + fillColor = style.fillColor + ) + } + + // Draw line + if (screenPoints.size > 1) { + drawLine( + points = screenPoints, + lineColor = style.lineColor, + lineWidth = style.lineWidth + ) + } + + // Draw data points + screenPoints.forEach { point -> + drawCircle( + color = style.lineColor, + radius = style.lineWidth * 1.5f, + center = point + ) + drawCircle( + color = style.backgroundColor, + radius = style.lineWidth * 0.8f, + center = point + ) + } + } + } +} + +private fun DrawScope.drawGrid( + padding: Float, + chartWidth: Float, + chartHeight: Float, + gridColor: Color, + minX: Float, + maxX: Float, + minY: Float, + maxY: Float, + textMeasurer: TextMeasurer, + textColor: Color, + xAxisFormatter: (Float) -> String, + yAxisFormatter: (Float) -> String, + actualDataPoints: List +) { + val textStyle = TextStyle( + color = textColor, + fontSize = 10.sp + ) + + // Draw vertical grid lines (X-axis) - only at integer values for sessions + val xRange = maxX - minX + if (xRange > 0) { + val startX = kotlin.math.ceil(minX).toInt() + val endX = kotlin.math.floor(maxX).toInt() + + for (sessionNum in startX..endX) { + val x = padding + (sessionNum.toFloat() - minX) / xRange * chartWidth + + // Draw grid line + drawLine( + color = gridColor, + start = Offset(x, padding), + end = Offset(x, padding + chartHeight), + strokeWidth = 1.dp.toPx() + ) + + // Draw label + val text = xAxisFormatter(sessionNum.toFloat()) + val textSize = textMeasurer.measure(text, textStyle) + drawText( + textMeasurer = textMeasurer, + text = text, + style = textStyle, + topLeft = Offset( + x - textSize.size.width / 2f, + padding + chartHeight + 8.dp.toPx() + ) + ) + } + } + + // Draw horizontal grid lines (Y-axis) - only at actual data point values + val yRange = maxY - minY + if (yRange > 0) { + // Get unique Y values from actual data points + val actualYValues = actualDataPoints.map { kotlin.math.round(it.y).toInt() }.toSet() + + actualYValues.forEach { gradeValue -> + val y = padding + chartHeight - (gradeValue.toFloat() - minY) / yRange * chartHeight + + // Only draw if within chart bounds + if (y >= padding && y <= padding + chartHeight) { + // Draw grid line + drawLine( + color = gridColor, + start = Offset(padding, y), + end = Offset(padding + chartWidth, y), + strokeWidth = 1.dp.toPx() + ) + + // Draw label + val text = yAxisFormatter(gradeValue.toFloat()) + val textSize = textMeasurer.measure(text, textStyle) + drawText( + textMeasurer = textMeasurer, + text = text, + style = textStyle, + topLeft = Offset( + padding - textSize.size.width - 8.dp.toPx(), + y - textSize.size.height / 2f + ) + ) + } + } + } +} + +private fun DrawScope.drawAreaFill( + points: List, + padding: Float, + chartHeight: Float, + fillColor: Color +) { + val bottomY = padding + chartHeight // This represents the bottom of the chart area + + val path = Path().apply { + // Start from bottom-left (at chart bottom level) + moveTo(points.first().x, bottomY) + + // Draw to first point + lineTo(points.first().x, points.first().y) + + // Draw line through all points + for (i in 1 until points.size) { + lineTo(points[i].x, points[i].y) + } + + // Close the path by going to bottom-right (at chart bottom level) and back to start + lineTo(points.last().x, bottomY) + lineTo(points.first().x, bottomY) + close() + } + + drawPath( + path = path, + color = fillColor + ) +} + +private fun DrawScope.drawLine( + points: List, + lineColor: Color, + lineWidth: Float +) { + val path = Path().apply { + moveTo(points.first().x, points.first().y) + for (i in 1 until points.size) { + lineTo(points[i].x, points[i].y) + } + } + + drawPath( + path = path, + color = lineColor, + style = Stroke( + width = lineWidth, + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) +} 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 3bbfa92..fb64ea4 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext import com.atridad.openclimb.data.model.* import com.atridad.openclimb.ui.components.ImagePicker import com.atridad.openclimb.ui.viewmodel.ClimbViewModel @@ -80,7 +81,7 @@ fun AddEditGymScreen( val gym = Gym.create(name, location, selectedClimbTypes.toList(), selectedDifficultySystems.toList(), notes = notes) if (isEditing) { - viewModel.updateGym(gym.copy(id = gymId)) + viewModel.updateGym(gym.copy(id = gymId!!)) } else { viewModel.addGym(gym) } @@ -348,7 +349,7 @@ fun AddEditProblemScreen( ) if (isEditing) { - viewModel.updateProblem(problem.copy(id = problemId)) + viewModel.updateProblem(problem.copy(id = problemId!!)) } else { viewModel.addProblem(problem) } @@ -565,7 +566,7 @@ fun AddEditProblemScreen( trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier - .menuAnchor() + .menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true) .fillMaxWidth() ) ExposedDropdownMenu( @@ -688,6 +689,7 @@ fun AddEditSessionScreen( ) { val isEditing = sessionId != null val gyms by viewModel.gyms.collectAsState() + val context = LocalContext.current // Session form state var selectedGym by remember { mutableStateOf(gymId?.let { id -> gyms.find { it.id == id } }) } @@ -727,15 +729,14 @@ fun AddEditSessionScreen( TextButton( onClick = { selectedGym?.let { gym -> - val session = ClimbSession.create( - gymId = gym.id, - notes = sessionNotes.ifBlank { null } - ) - if (isEditing) { - viewModel.updateSession(session.copy(id = sessionId)) + val session = ClimbSession.create( + gymId = gym.id, + notes = sessionNotes.ifBlank { null } + ) + viewModel.updateSession(session.copy(id = sessionId!!)) } else { - viewModel.addSession(session) + viewModel.startSession(context, gym.id, sessionNotes.ifBlank { null }) } onNavigateBack() } 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 c9de849..4532466 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 @@ -11,6 +11,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.atridad.openclimb.R import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import com.atridad.openclimb.data.model.ClimbType +import com.atridad.openclimb.data.model.DifficultySystem +import com.atridad.openclimb.ui.components.ChartDataPoint +import com.atridad.openclimb.ui.components.LineChart @Composable fun AnalyticsScreen( @@ -57,20 +61,10 @@ fun AnalyticsScreen( ) } - // Success Rate + // Progress Chart item { - val successfulAttempts = attempts.count { - it.result.name in listOf("SUCCESS", "FLASH", "REDPOINT", "ONSIGHT") - } - val successRate = if (attempts.isNotEmpty()) { - (successfulAttempts.toDouble() / attempts.size * 100).toInt() - } else 0 - - SuccessRateCard( - successRate = successRate, - successfulAttempts = successfulAttempts, - totalAttempts = attempts.size - ) + val progressData = calculateProgressOverTime(sessions, problems, attempts) + ProgressChartCard(progressData = progressData, problems = problems) } // Favorite Gym @@ -132,14 +126,24 @@ fun OverallStatsCard( } } - - +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SuccessRateCard( - successRate: Int, - successfulAttempts: Int, - totalAttempts: Int +fun ProgressChartCard( + progressData: List, + problems: List, ) { + // Find all grading systems that have been used + val usedSystems = remember(problems) { + problems.map { it.difficulty.system }.distinct().filter { system -> + problems.any { it.difficulty.system == system } + } + } + + var selectedSystem by remember { + mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) + } + var expanded by remember { mutableStateOf(false) } + Card( modifier = Modifier.fillMaxWidth() ) { @@ -148,38 +152,120 @@ fun SuccessRateCard( .fillMaxWidth() .padding(16.dp) ) { - Text( - text = "Success Rate", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "$successRate%", - style = MaterialTheme.typography.displaySmall, + text = "Progress Over Time", + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + modifier = Modifier.weight(1f) ) - Column(horizontalAlignment = Alignment.End) { - Text( - text = "$successfulAttempts successful", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "out of $totalAttempts attempts", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // Scale selector dropdown + if (usedSystems.size > 1) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = when (selectedSystem) { + DifficultySystem.V_SCALE -> "V-Scale" + DifficultySystem.FONT -> "Font" + DifficultySystem.YDS -> "YDS" + DifficultySystem.CUSTOM -> "Custom" + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true) + .width(120.dp), + textStyle = MaterialTheme.typography.bodyMedium + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + usedSystems.forEach { system -> + DropdownMenuItem( + text = { + Text(when (system) { + DifficultySystem.V_SCALE -> "V-Scale" + DifficultySystem.FONT -> "Font" + DifficultySystem.YDS -> "YDS" + DifficultySystem.CUSTOM -> "Custom" + }) + }, + onClick = { + selectedSystem = system + expanded = false + } + ) + } + } + } } } + + Spacer(modifier = Modifier.height(12.dp)) + + // Filter progress data by selected scale + val filteredProgressData = remember(progressData, selectedSystem) { + progressData.filter { it.difficultySystem == selectedSystem } + } + + if (filteredProgressData.isNotEmpty()) { + val chartData = remember(filteredProgressData) { + // Convert progress data to chart data points ordered by session + filteredProgressData + .sortedBy { it.date } + .mapIndexed { index, p -> + ChartDataPoint( + x = (index + 1).toFloat(), + y = p.maxGradeNumeric.toFloat(), + label = "Session ${index + 1}" + ) + } + } + + LineChart( + data = chartData, + modifier = Modifier.fillMaxWidth().height(220.dp), + xAxisFormatter = { value -> + "S${value.toInt()}" // S1, S2, S3, etc. + }, + yAxisFormatter = { value -> + numericToGrade(selectedSystem, value.toInt()) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "X: session number, Y: max ${when(selectedSystem) { + DifficultySystem.V_SCALE -> "V-grade" + DifficultySystem.FONT -> "Font grade" + DifficultySystem.YDS -> "YDS grade" + DifficultySystem.CUSTOM -> "custom grade" + }} achieved", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = "No progress data available for ${when(selectedSystem) { + DifficultySystem.V_SCALE -> "V-Scale" + DifficultySystem.FONT -> "Font" + DifficultySystem.YDS -> "YDS" + DifficultySystem.CUSTOM -> "Custom" + }} system", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -253,3 +339,204 @@ fun RecentActivityCard( } } } + +data class ProgressDataPoint( + val date: String, + val maxGrade: String, + val maxGradeNumeric: Int, + val climbType: ClimbType, + val difficultySystem: DifficultySystem +) + +fun calculateProgressOverTime( + sessions: List, + problems: List, + attempts: List +): List { + if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) { + return emptyList() + } + + val sessionProgress = sessions.mapNotNull { session -> + val sessionAttempts = attempts.filter { it.sessionId == session.id } + if (sessionAttempts.isEmpty()) return@mapNotNull null + val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct() + val attemptedProblems = problems.filter { it.id in attemptedProblemIds } + if (attemptedProblems.isEmpty()) return@mapNotNull null + val highestGradeProblem = attemptedProblems.maxByOrNull { problem -> + gradeToNumeric(problem.difficulty.system, problem.difficulty.grade) + } + if (highestGradeProblem != null) { + ProgressDataPoint( + date = session.date, + maxGrade = highestGradeProblem.difficulty.grade, + maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade), + climbType = highestGradeProblem.climbType, + difficultySystem = highestGradeProblem.difficulty.system + ) + } else null + } + return sessionProgress.sortedBy { it.date } +} + +fun gradeToNumeric(system: DifficultySystem, grade: String): Int { + return when (system) { + DifficultySystem.V_SCALE -> { + when (grade) { + "VB" -> 0 + else -> grade.removePrefix("V").toIntOrNull() ?: 0 + } + } + DifficultySystem.FONT -> { + when (grade) { + "3" -> 3 + "4A" -> 4 + "4B" -> 5 + "4C" -> 6 + "5A" -> 7 + "5B" -> 8 + "5C" -> 9 + "6A" -> 10 + "6A+" -> 11 + "6B" -> 12 + "6B+" -> 13 + "6C" -> 14 + "6C+" -> 15 + "7A" -> 16 + "7A+" -> 17 + "7B" -> 18 + "7B+" -> 19 + "7C" -> 20 + "7C+" -> 21 + "8A" -> 22 + "8A+" -> 23 + "8B" -> 24 + "8B+" -> 25 + "8C" -> 26 + "8C+" -> 27 + else -> 0 + } + } + DifficultySystem.YDS -> { + when (grade) { + "5.0" -> 50 + "5.1" -> 51 + "5.2" -> 52 + "5.3" -> 53 + "5.4" -> 54 + "5.5" -> 55 + "5.6" -> 56 + "5.7" -> 57 + "5.8" -> 58 + "5.9" -> 59 + "5.10a" -> 60 + "5.10b" -> 61 + "5.10c" -> 62 + "5.10d" -> 63 + "5.11a" -> 64 + "5.11b" -> 65 + "5.11c" -> 66 + "5.11d" -> 67 + "5.12a" -> 68 + "5.12b" -> 69 + "5.12c" -> 70 + "5.12d" -> 71 + "5.13a" -> 72 + "5.13b" -> 73 + "5.13c" -> 74 + "5.13d" -> 75 + "5.14a" -> 76 + "5.14b" -> 77 + "5.14c" -> 78 + "5.14d" -> 79 + "5.15a" -> 80 + "5.15b" -> 81 + "5.15c" -> 82 + "5.15d" -> 83 + else -> 0 + } + } + DifficultySystem.CUSTOM -> 0 + } +} + +fun numericToGrade(system: DifficultySystem, numeric: Int): String { + return when (system) { + DifficultySystem.V_SCALE -> { + when (numeric) { + 0 -> "VB" + else -> "V$numeric" + } + } + DifficultySystem.FONT -> { + when (numeric) { + 3 -> "3" + 4 -> "4A" + 5 -> "4B" + 6 -> "4C" + 7 -> "5A" + 8 -> "5B" + 9 -> "5C" + 10 -> "6A" + 11 -> "6A+" + 12 -> "6B" + 13 -> "6B+" + 14 -> "6C" + 15 -> "6C+" + 16 -> "7A" + 17 -> "7A+" + 18 -> "7B" + 19 -> "7B+" + 20 -> "7C" + 21 -> "7C+" + 22 -> "8A" + 23 -> "8A+" + 24 -> "8B" + 25 -> "8B+" + 26 -> "8C" + 27 -> "8C+" + else -> numeric.toString() + } + } + DifficultySystem.YDS -> { + when (numeric) { + 50 -> "5.0" + 51 -> "5.1" + 52 -> "5.2" + 53 -> "5.3" + 54 -> "5.4" + 55 -> "5.5" + 56 -> "5.6" + 57 -> "5.7" + 58 -> "5.8" + 59 -> "5.9" + 60 -> "5.10a" + 61 -> "5.10b" + 62 -> "5.10c" + 63 -> "5.10d" + 64 -> "5.11a" + 65 -> "5.11b" + 66 -> "5.11c" + 67 -> "5.11d" + 68 -> "5.12a" + 69 -> "5.12b" + 70 -> "5.12c" + 71 -> "5.12d" + 72 -> "5.13a" + 73 -> "5.13b" + 74 -> "5.13c" + 75 -> "5.13d" + 76 -> "5.14a" + 77 -> "5.14b" + 78 -> "5.14c" + 79 -> "5.14d" + 80 -> "5.15a" + 81 -> "5.15b" + 82 -> "5.15c" + 83 -> "5.15d" + else -> numeric.toString() + } + } + DifficultySystem.CUSTOM -> numeric.toString() + } +} 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 4347ea4..c7f72fb 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 @@ -426,11 +426,6 @@ fun SessionDetailScreen( label = "Completed", value = completedProblems.size.toString() ) - StatItem( - label = "Success Rate", - value = "${((successfulAttempts.size.toDouble() / attempts.size) * 100).toInt()}%" - ) - } // Show grade range(s) with better layout @@ -622,10 +617,6 @@ fun ProblemDetailScreen( // 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 @@ -793,7 +784,6 @@ fun ProblemDetailScreen( label = "Successful", value = successfulAttempts.size.toString() ) - StatItem(label = "Success Rate", value = "$successRate%") } Spacer(modifier = Modifier.height(12.dp)) @@ -929,10 +919,7 @@ fun GymDetailScreen( 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 @@ -1042,19 +1029,7 @@ fun GymDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "$successRate%", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "Success Rate", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + } Spacer(modifier = Modifier.height(12.dp)) @@ -2001,7 +1976,7 @@ fun EnhancedAddAttemptDialog( colors = ExposedDropdownMenuDefaults .outlinedTextFieldColors(), - modifier = Modifier.menuAnchor().fillMaxWidth(), + modifier = Modifier.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, enabled = true).fillMaxWidth(), isError = newProblemGrade.isBlank(), supportingText = if (newProblemGrade.isBlank()) { 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 4d16cfa..ad32149 100644 --- a/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt +++ b/app/src/main/java/com/atridad/openclimb/utils/SessionShareUtils.kt @@ -285,11 +285,7 @@ object SessionShareUtils { drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f) } - // Success rate arc - val successRate = if (stats.totalAttempts > 0) { - (stats.successfulAttempts.toFloat() / stats.totalAttempts) * 100f - } else 0f - drawSuccessRateArc(canvas, width / 2f, height - 300f, successRate, statLabelPaint, statValuePaint) + // App branding val brandingPaint = Paint().apply { @@ -372,52 +368,7 @@ object SessionShareUtils { return "${sorted.first().grade} - ${sorted.last().grade}" } - private fun drawSuccessRateArc( - canvas: Canvas, - centerX: Float, - centerY: Float, - successRate: Float, - labelPaint: Paint, - valuePaint: Paint - ) { - val radius = 70f - val strokeWidth = 14f - // Background arc - val bgPaint = Paint().apply { - color = "#30FFFFFF".toColorInt() - style = Paint.Style.STROKE - this.strokeWidth = strokeWidth - isAntiAlias = true - strokeCap = Paint.Cap.ROUND - } - - // Success arc - val successPaint = Paint().apply { - color = "#4CAF50".toColorInt() - style = Paint.Style.STROKE - this.strokeWidth = strokeWidth - isAntiAlias = true - strokeCap = Paint.Cap.ROUND - } - - val rect = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius) - - // Draw background arc (full circle) - canvas.drawArc(rect, -90f, 360f, false, bgPaint) - - // Draw success arc - val sweepAngle = (successRate / 100f) * 360f - canvas.drawArc(rect, -90f, sweepAngle, false, successPaint) - - // Draw percentage text - val percentText = "${successRate.roundToInt()}%" - canvas.drawText(percentText, centerX, centerY + 8f, valuePaint) - - // Draw label below the arc (outside the ring) for better readability - val belowLabelPaint = Paint(labelPaint) - canvas.drawText("Success Rate", centerX, centerY + radius + 36f, belowLabelPaint) - } private fun formatSessionDate(dateString: String): String { return try { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dd71d3..afe0217 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,24 @@ [versions] agp = "8.12.1" -kotlin = "2.0.21" -coreKtx = "1.15.0" +kotlin = "2.2.10" +coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -androidxTestCore = "1.6.0" -androidxTestExt = "1.2.0" -androidxTestRunner = "1.6.0" -androidxTestRules = "1.6.0" -lifecycleRuntimeKtx = "2.9.2" +androidxTestCore = "1.7.0" +androidxTestExt = "1.3.0" +androidxTestRunner = "1.7.0" +androidxTestRules = "1.7.0" +lifecycleRuntimeKtx = "2.9.3" activityCompose = "1.10.1" -composeBom = "2024.09.00" -room = "2.6.1" -navigation = "2.8.4" -viewmodel = "2.9.2" -kotlinxSerialization = "1.7.1" -kotlinxCoroutines = "1.9.0" +composeBom = "2025.08.01" +room = "2.7.2" +navigation = "2.9.3" +viewmodel = "2.9.3" +kotlinxSerialization = "1.9.0" +kotlinxCoroutines = "1.10.2" coil = "2.7.0" -ksp = "2.0.21-1.0.25" +ksp = "2.2.10-2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -64,8 +64,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" } # Image Loading coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -# Charts - MPAndroidChart for now, will be replaced with Vico when stable -mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version = "v3.1.0" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" }