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" }