diff --git a/android/.kotlin/sessions/kotlin-compiler-8439154287983817179.salive b/android/.kotlin/sessions/kotlin-compiler-8439154287983817179.salive new file mode 100644 index 0000000..e69de29 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 786c604..be6b465 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 23 - versionName = "1.4.2" + versionCode = 24 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -55,6 +55,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) // Room Database implementation(libs.androidx.room.runtime) @@ -92,4 +93,3 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) } - diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt new file mode 100644 index 0000000..01b2732 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt @@ -0,0 +1,208 @@ +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.material3.MaterialTheme +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.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 bar chart */ +data class BarChartDataPoint(val label: String, val value: Int, val gradeNumeric: Int) + +/** Configuration for bar chart styling */ +data class BarChartStyle( + val barColor: Color, + val gridColor: Color, + val textColor: Color, + val backgroundColor: Color +) + +/** Custom Bar Chart for displaying grade distribution */ +@Composable +fun BarChart( + data: List, + modifier: Modifier = Modifier, + style: BarChartStyle = + BarChartStyle( + barColor = MaterialTheme.colorScheme.primary, + gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surface + ), + showGrid: Boolean = true +) { + 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 + + // Sort data by grade numeric value for proper ordering + val sortedData = data.sortedBy { it.gradeNumeric } + + // Calculate max value for scaling + val maxValue = sortedData.maxOfOrNull { it.value } ?: 1 + + // Calculate bar dimensions + val barCount = sortedData.size + val totalSpacing = chartWidth * 0.2f // 20% of width for spacing + val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2 + val barWidth = (chartWidth - totalSpacing) / barCount + + // 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, + maxValue = maxValue, + textMeasurer = textMeasurer, + textColor = style.textColor + ) + } + + // Draw bars and labels + sortedData.forEachIndexed { index, dataPoint -> + val barHeight = + if (maxValue > 0) { + (dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f + } else 0f + + val barX = + padding + + barSpacing + + index * (barWidth + barSpacing / (barCount - 1).coerceAtLeast(1)) + val barY = padding + chartHeight - barHeight + + // Draw bar + drawRect( + color = style.barColor, + topLeft = Offset(barX, barY), + size = androidx.compose.ui.geometry.Size(barWidth, barHeight) + ) + + // Draw value on top of bar (if there's space) + if (dataPoint.value > 0) { + val valueText = dataPoint.value.toString() + val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp) + val textSize = textMeasurer.measure(valueText, textStyle) + + // Position text on top of bar or inside if bar is tall enough + val textY = + if (barHeight > textSize.size.height + 8.dp.toPx()) { + barY + 8.dp.toPx() // Inside bar + } else { + barY - 4.dp.toPx() // Above bar + } + + val textColor = + if (barHeight > textSize.size.height + 8.dp.toPx()) { + Color.White // White text inside bar + } else { + style.textColor // Regular color above bar + } + + drawText( + textMeasurer = textMeasurer, + text = valueText, + style = textStyle.copy(color = textColor), + topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY) + ) + } + + // Draw grade label below bar + val gradeText = dataPoint.label + val labelTextStyle = TextStyle(color = style.textColor, fontSize = 10.sp) + val labelTextSize = textMeasurer.measure(gradeText, labelTextStyle) + + drawText( + textMeasurer = textMeasurer, + text = gradeText, + style = labelTextStyle, + topLeft = + Offset( + barX + barWidth / 2f - labelTextSize.size.width / 2f, + padding + chartHeight + 8.dp.toPx() + ) + ) + } + } + } +} + +private fun DrawScope.drawGrid( + padding: Float, + chartWidth: Float, + chartHeight: Float, + gridColor: Color, + maxValue: Int, + textMeasurer: TextMeasurer, + textColor: Color +) { + val textStyle = TextStyle(color = textColor, fontSize = 10.sp) + + // Draw horizontal grid lines (Y-axis) + val gridLines = + when { + maxValue <= 5 -> (0..maxValue).toList() + maxValue <= 10 -> (0..maxValue step 2).toList() + maxValue <= 20 -> (0..maxValue step 5).toList() + else -> { + val step = (maxValue / 5).coerceAtLeast(1) + (0..maxValue step step).toList() + } + } + + gridLines.forEach { value -> + val y = padding + chartHeight - (value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f + + // Draw grid line + drawLine( + color = gridColor, + start = Offset(padding, y), + end = Offset(padding + chartWidth, y), + strokeWidth = 1.dp.toPx() + ) + + // Draw Y-axis label + if (value >= 0) { + val text = value.toString() + 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 + ) + ) + } + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt index 3349fe2..33acbed 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AnalyticsScreen.kt @@ -10,78 +10,76 @@ import androidx.compose.ui.res.painterResource 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.AttemptResult 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 +import com.atridad.openclimb.ui.components.BarChart +import com.atridad.openclimb.ui.components.BarChartDataPoint +import com.atridad.openclimb.ui.viewmodel.ClimbViewModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter @Composable -fun AnalyticsScreen( - viewModel: ClimbViewModel -) { +fun AnalyticsScreen(viewModel: ClimbViewModel) { val sessions by viewModel.sessions.collectAsState() val problems by viewModel.problems.collectAsState() val attempts by viewModel.attempts.collectAsState() val gyms by viewModel.gyms.collectAsState() - + LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_mountains), - contentDescription = "OpenClimb Logo", - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + painter = painterResource(id = R.drawable.ic_mountains), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary ) Text( - text = "Analytics", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Analytics", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) } } - + // Overall Stats item { OverallStatsCard( - totalSessions = sessions.size, - totalProblems = problems.size, - totalAttempts = attempts.size, - totalGyms = gyms.size + totalSessions = sessions.size, + totalProblems = problems.size, + totalAttempts = attempts.size, + totalGyms = gyms.size ) } - - // Progress Chart + + // Grade Distribution Chart item { - val progressData = calculateProgressOverTime(sessions, problems, attempts) - ProgressChartCard(progressData = progressData, problems = problems) + val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts) + GradeDistributionChartCard(gradeDistributionData = gradeDistributionData) } - + // Favorite Gym item { - val favoriteGym = sessions - .groupBy { it.gymId } - .maxByOrNull { it.value.size } - ?.let { (gymId, sessions) -> - gyms.find { it.id == gymId }?.name to sessions.size - } - + val favoriteGym = + sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { + (gymId, sessions) -> + gyms.find { it.id == gymId }?.name to sessions.size + } + FavoriteGymCard( - gymName = favoriteGym?.first ?: "No sessions yet", - sessionCount = favoriteGym?.second ?: 0 + gymName = favoriteGym?.first ?: "No sessions yet", + sessionCount = favoriteGym?.second ?: 0 ) } - + // Recent Activity item { val recentSessions = sessions.take(5) @@ -91,31 +89,20 @@ fun AnalyticsScreen( } @Composable -fun OverallStatsCard( - totalSessions: Int, - totalProblems: Int, - totalAttempts: Int, - totalGyms: Int -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { +fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "Overall Stats", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Overall Stats", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(12.dp)) - + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { StatItem(label = "Sessions", value = totalSessions.toString()) StatItem(label = "Problems", value = totalProblems.toString()) @@ -128,178 +115,241 @@ fun OverallStatsCard( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProgressChartCard( - progressData: List, - problems: List, -) { - // Find all grading systems that have been used in the progress data - val usedSystems = remember(progressData) { - progressData.map { it.difficultySystem }.distinct() - } - - var selectedSystem by remember(usedSystems) { - mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) - } +fun GradeDistributionChartCard(gradeDistributionData: List) { + // Find all grading systems that have been used in the data + val usedSystems = + remember(gradeDistributionData) { + gradeDistributionData.map { it.difficultySystem }.distinct() + } + + var selectedSystem by + remember(usedSystems) { + mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) + } var expanded by remember { mutableStateOf(false) } - - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Progress Over Time", + var showAllTime by remember { mutableStateOf(true) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "Grade Distribution", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Toggles section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Time period toggle + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // All Time button + FilterChip( + onClick = { showAllTime = true }, + label = { + Text("All Time", style = MaterialTheme.typography.bodySmall) + }, + selected = showAllTime, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary + ) + ) + + // 7 Days button + FilterChip( + onClick = { showAllTime = false }, + label = { Text("7 Days", style = MaterialTheme.typography.bodySmall) }, + selected = !showAllTime, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = + MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + // Scale selector dropdown if (usedSystems.size > 1) { ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } + 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) { + value = + when (selectedSystem) { DifficultySystem.V_SCALE -> "V-Scale" DifficultySystem.FONT -> "Font" DifficultySystem.YDS -> "YDS" DifficultySystem.CUSTOM -> "Custom" - }) - }, - onClick = { - selectedSystem = system - expanded = false - } + }, + 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}" - ) + + // Filter grade distribution data by selected scale and time period + val filteredGradeData = + remember(gradeDistributionData, selectedSystem, showAllTime) { + val systemFiltered = + gradeDistributionData.filter { + it.difficultySystem == selectedSystem + } + + if (showAllTime) { + systemFiltered + } else { + // Filter for last 7 days + val sevenDaysAgo = LocalDateTime.now().minusDays(7) + systemFiltered.filter { dataPoint -> + try { + val attemptDate = + LocalDateTime.parse( + dataPoint.date, + DateTimeFormatter.ISO_LOCAL_DATE_TIME + ) + attemptDate.isAfter(sevenDaysAgo) + } catch (e: Exception) { + // If date parsing fails, include the data point + true + } + } } - } - - LineChart( - data = chartData, - modifier = Modifier.fillMaxWidth().height(220.dp), - xAxisFormatter = { value -> - "S${value.toInt()}" // S1, S2, S3, etc. - }, - yAxisFormatter = { value -> - numericToGrade(selectedSystem, value.toInt()) } - ) - + + if (filteredGradeData.isNotEmpty()) { + // Group by grade and sum counts + val gradeGroups = + filteredGradeData + .groupBy { it.grade } + .mapValues { (_, dataPoints) -> dataPoints.sumOf { it.count } } + .map { (grade, count) -> + val firstDataPoint = + filteredGradeData.first { it.grade == grade } + BarChartDataPoint( + label = grade, + value = count, + gradeNumeric = firstDataPoint.gradeNumeric + ) + } + + BarChart(data = gradeGroups, modifier = Modifier.fillMaxWidth().height(220.dp)) + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = "X: session number, Y: max ${when(selectedSystem) { + text = + "Successful climbs by ${when(selectedSystem) { DifficultySystem.V_SCALE -> "V-grade" - DifficultySystem.FONT -> "Font grade" + DifficultySystem.FONT -> "Font grade" DifficultySystem.YDS -> "YDS grade" DifficultySystem.CUSTOM -> "custom grade" - }} achieved", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + }}", + 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 - ) + Column( + modifier = Modifier.fillMaxWidth().height(220.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_mountains), + contentDescription = "No data", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "No data available.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = + if (showAllTime) + "Complete some climbs to see your grade distribution!" + else "No climbs in the last 7 days", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } } } } } @Composable -fun FavoriteGymCard( - gymName: String, - sessionCount: Int -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { +fun FavoriteGymCard(gymName: String, sessionCount: Int) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "Favorite Gym", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Favorite Gym", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = gymName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium + text = gymName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium ) - + if (sessionCount > 0) { Text( - text = "$sessionCount sessions", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "$sessionCount sessions", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -307,74 +357,92 @@ fun FavoriteGymCard( } @Composable -fun RecentActivityCard( - recentSessions: Int -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { +fun RecentActivityCard(recentSessions: Int) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "Recent Activity", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Recent Activity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = if (recentSessions > 0) { - "You've had $recentSessions recent sessions" - } else { - "No recent activity" - }, - style = MaterialTheme.typography.bodyMedium + text = + if (recentSessions > 0) { + "You've had $recentSessions recent sessions" + } else { + "No recent activity" + }, + style = MaterialTheme.typography.bodyMedium ) } } } -data class ProgressDataPoint( - val date: String, - val maxGrade: String, - val maxGradeNumeric: Int, - val climbType: ClimbType, - val difficultySystem: DifficultySystem +data class GradeDistributionDataPoint( + val date: String, + val grade: String, + val gradeNumeric: Int, + val count: Int, + val climbType: ClimbType, + val difficultySystem: DifficultySystem ) -fun calculateProgressOverTime( - sessions: List, - problems: List, - attempts: List -): List { +fun calculateGradeDistribution( + 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 + + // Get all successful attempts + val successfulAttempts = + attempts.filter { + it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH + } + + if (successfulAttempts.isEmpty()) { + return emptyList() } - return sessionProgress.sortedBy { it.date } + + // Map attempts to problems and create grade distribution data + val gradeDistribution = mutableMapOf() + + successfulAttempts.forEach { attempt -> + val problem = problems.find { it.id == attempt.problemId } + val session = sessions.find { it.id == attempt.sessionId } + + if (problem != null && session != null) { + val key = "${problem.difficulty.system.name}-${problem.difficulty.grade}" + + val existing = gradeDistribution[key] + if (existing != null) { + gradeDistribution[key] = existing.copy(count = existing.count + 1) + } else { + gradeDistribution[key] = + GradeDistributionDataPoint( + date = + attempt.timestamp + .toString(), // Use attempt timestamp for filtering + grade = problem.difficulty.grade, + gradeNumeric = + gradeToNumeric( + problem.difficulty.system, + problem.difficulty.grade + ), + count = 1, + climbType = problem.climbType, + difficultySystem = problem.difficulty.system + ) + } + } + } + + return gradeDistribution.values.toList() } fun gradeToNumeric(system: DifficultySystem, grade: String): Int { @@ -460,84 +528,3 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int { } } } - -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/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d4d919a..38d2547 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -agp = "8.12.2" -kotlin = "2.2.10" +agp = "8.12.3" +kotlin = "2.2.20" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -9,12 +9,12 @@ 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 = "2025.08.01" -room = "2.7.2" -navigation = "2.9.3" -viewmodel = "2.9.3" +lifecycleRuntimeKtx = "2.9.4" +activityCompose = "1.11.0" +composeBom = "2025.09.01" +room = "2.8.1" +navigation = "2.9.5" +viewmodel = "2.9.4" kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" @@ -39,6 +39,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } # Room Database androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } @@ -59,7 +60,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } # Testing -mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" } +mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } # Image Loading coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } @@ -72,4 +73,3 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } - diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 3fcb8e5..e8446e6 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ