[Android] 1.5.0

This commit is contained in:
2025-09-24 17:15:53 -06:00
parent eddd17a704
commit b6dded50d9
6 changed files with 536 additions and 341 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 24
versionName = "1.4.2" versionName = "1.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -55,6 +55,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database // Room Database
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
@@ -92,4 +93,3 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
} }

View File

@@ -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<BarChartDataPoint>,
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
)
)
}
}
}

View File

@@ -10,43 +10,42 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R 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.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.LineChart import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable @Composable
fun AnalyticsScreen( fun AnalyticsScreen(viewModel: ClimbViewModel) {
viewModel: ClimbViewModel
) {
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(16.dp),
.fillMaxSize() verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "Analytics", text = "Analytics",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
} }
@@ -54,31 +53,30 @@ fun AnalyticsScreen(
// Overall Stats // Overall Stats
item { item {
OverallStatsCard( OverallStatsCard(
totalSessions = sessions.size, totalSessions = sessions.size,
totalProblems = problems.size, totalProblems = problems.size,
totalAttempts = attempts.size, totalAttempts = attempts.size,
totalGyms = gyms.size totalGyms = gyms.size
) )
} }
// Progress Chart // Grade Distribution Chart
item { item {
val progressData = calculateProgressOverTime(sessions, problems, attempts) val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
ProgressChartCard(progressData = progressData, problems = problems) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
} }
// Favorite Gym // Favorite Gym
item { item {
val favoriteGym = sessions val favoriteGym =
.groupBy { it.gymId } sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
.maxByOrNull { it.value.size } (gymId, sessions) ->
?.let { (gymId, sessions) -> gyms.find { it.id == gymId }?.name to sessions.size
gyms.find { it.id == gymId }?.name to sessions.size }
}
FavoriteGymCard( FavoriteGymCard(
gymName = favoriteGym?.first ?: "No sessions yet", gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0 sessionCount = favoriteGym?.second ?: 0
) )
} }
@@ -91,31 +89,20 @@ fun AnalyticsScreen(
} }
@Composable @Composable
fun OverallStatsCard( fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) {
totalSessions: Int, Card(modifier = Modifier.fillMaxWidth()) {
totalProblems: Int, Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
totalAttempts: Int,
totalGyms: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Overall Stats", text = "Overall Stats",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
StatItem(label = "Sessions", value = totalSessions.toString()) StatItem(label = "Sessions", value = totalSessions.toString())
StatItem(label = "Problems", value = totalProblems.toString()) StatItem(label = "Problems", value = totalProblems.toString())
@@ -128,79 +115,114 @@ fun OverallStatsCard(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProgressChartCard( fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionDataPoint>) {
progressData: List<ProgressDataPoint>, // Find all grading systems that have been used in the data
problems: List<com.atridad.openclimb.data.model.Problem>, val usedSystems =
) { remember(gradeDistributionData) {
// Find all grading systems that have been used in the progress data gradeDistributionData.map { it.difficultySystem }.distinct()
val usedSystems = remember(progressData) { }
progressData.map { it.difficultySystem }.distinct()
}
var selectedSystem by remember(usedSystems) { var selectedSystem by
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) remember(usedSystems) {
} mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showAllTime by remember { mutableStateOf(true) }
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) { Text(
Column( text = "Grade Distribution",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Progress Over Time",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold
modifier = Modifier.weight(1f) )
)
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 // Scale selector dropdown
if (usedSystems.size > 1) { if (usedSystems.size > 1) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = !expanded } onExpandedChange = { expanded = !expanded }
) { ) {
OutlinedTextField( OutlinedTextField(
value = when (selectedSystem) { value =
DifficultySystem.V_SCALE -> "V-Scale" when (selectedSystem) {
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.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font" DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS" DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom" DifficultySystem.CUSTOM -> "Custom"
}) },
}, onValueChange = {},
onClick = { readOnly = true,
selectedSystem = system trailingIcon = {
expanded = false 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
}
) )
} }
} }
@@ -210,96 +232,124 @@ fun ProgressChartCard(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale // Filter grade distribution data by selected scale and time period
val filteredProgressData = remember(progressData, selectedSystem) { val filteredGradeData =
progressData.filter { it.difficultySystem == selectedSystem } remember(gradeDistributionData, selectedSystem, showAllTime) {
} val systemFiltered =
gradeDistributionData.filter {
it.difficultySystem == selectedSystem
}
if (filteredProgressData.isNotEmpty()) { if (showAllTime) {
val chartData = remember(filteredProgressData) { systemFiltered
// Convert progress data to chart data points ordered by session } else {
filteredProgressData // Filter for last 7 days
.sortedBy { it.date } val sevenDaysAgo = LocalDateTime.now().minusDays(7)
.mapIndexed { index, p -> systemFiltered.filter { dataPoint ->
ChartDataPoint( try {
x = (index + 1).toFloat(), val attemptDate =
y = p.maxGradeNumeric.toFloat(), LocalDateTime.parse(
label = "Session ${index + 1}" 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)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "X: session number, Y: max ${when(selectedSystem) { text =
"Successful climbs by ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade" DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade" DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade" DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade" DifficultySystem.CUSTOM -> "custom grade"
}} achieved", }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} else { } else {
Text( Column(
text = "No progress data available for ${when(selectedSystem) { modifier = Modifier.fillMaxWidth().height(220.dp),
DifficultySystem.V_SCALE -> "V-Scale" horizontalAlignment = Alignment.CenterHorizontally,
DifficultySystem.FONT -> "Font" verticalArrangement = Arrangement.Center
DifficultySystem.YDS -> "YDS" ) {
DifficultySystem.CUSTOM -> "Custom" Icon(
}} system", painter = painterResource(id = R.drawable.ic_mountains),
style = MaterialTheme.typography.bodyMedium, contentDescription = "No data",
color = MaterialTheme.colorScheme.onSurfaceVariant 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 @Composable
fun FavoriteGymCard( fun FavoriteGymCard(gymName: String, sessionCount: Int) {
gymName: String, Card(modifier = Modifier.fillMaxWidth()) {
sessionCount: Int Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Favorite Gym", text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = gymName, text = gymName,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
if (sessionCount > 0) { if (sessionCount > 0) {
Text( Text(
text = "$sessionCount sessions", text = "$sessionCount sessions",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -307,74 +357,92 @@ fun FavoriteGymCard(
} }
@Composable @Composable
fun RecentActivityCard( fun RecentActivityCard(recentSessions: Int) {
recentSessions: Int Card(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Recent Activity", text = "Recent Activity",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = if (recentSessions > 0) { text =
"You've had $recentSessions recent sessions" if (recentSessions > 0) {
} else { "You've had $recentSessions recent sessions"
"No recent activity" } else {
}, "No recent activity"
style = MaterialTheme.typography.bodyMedium },
style = MaterialTheme.typography.bodyMedium
) )
} }
} }
} }
data class ProgressDataPoint( data class GradeDistributionDataPoint(
val date: String, val date: String,
val maxGrade: String, val grade: String,
val maxGradeNumeric: Int, val gradeNumeric: Int,
val climbType: ClimbType, val count: Int,
val difficultySystem: DifficultySystem val climbType: ClimbType,
val difficultySystem: DifficultySystem
) )
fun calculateProgressOverTime( fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>, sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt> attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> { ): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) { if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList() return emptyList()
} }
val sessionProgress = sessions.mapNotNull { session -> // Get all successful attempts
val sessionAttempts = attempts.filter { it.sessionId == session.id } val successfulAttempts =
if (sessionAttempts.isEmpty()) return@mapNotNull null attempts.filter {
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct() it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
val attemptedProblems = problems.filter { it.id in attemptedProblemIds } }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem -> if (successfulAttempts.isEmpty()) {
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade) return emptyList()
}
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 }
// Map attempts to problems and create grade distribution data
val gradeDistribution = mutableMapOf<String, GradeDistributionDataPoint>()
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 { 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()
}
}

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.12.2" agp = "8.12.3"
kotlin = "2.2.10" kotlin = "2.2.20"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -9,12 +9,12 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0" androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.3" lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.10.1" activityCompose = "1.11.0"
composeBom = "2025.08.01" composeBom = "2025.09.01"
room = "2.7.2" room = "2.8.1"
navigation = "2.9.3" navigation = "2.9.5"
viewmodel = "2.9.3" viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0" kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room Database # Room Database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } 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" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing # Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" } mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
# Image Loading # Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } 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-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }