Compare commits

...

7 Commits

Author SHA1 Message Date
416b68e96a [Android] 1.5.0 2025-09-24 17:15:53 -06:00
f68963afbc oops 2025-09-23 22:20:11 -06:00
f1bc61d202 1.0.2 - Widget and Photos fixes 2025-09-23 21:57:45 -06:00
57b16c89ad 1.0.1 (6) 2025-09-20 15:08:59 -06:00
44b9b7bb9e Release README 2025-09-20 14:33:16 -06:00
7839d52001 ??? 2025-09-20 14:32:12 -06:00
fff8123978 1.0.1 (5) 2025-09-20 14:32:04 -06:00
16 changed files with 1352 additions and 625 deletions

View File

@@ -5,7 +5,7 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems,
## Versions ## Versions
- Android:1.4.2 - Android:1.4.2
- iOS: 1.0.0 - iOS: 1.0.1
## Download ## Download
@@ -16,7 +16,7 @@ For Android do one of the following:
For iOS: For iOS:
**Stay tuned for an upcoming Testflight or App Store release!** Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
## Requirements ## Requirements

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

View File

@@ -40,6 +40,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -107,6 +108,7 @@
D24C195F2E75002A0045894C = { D24C195F2E75002A0045894C = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2FE947F2E78E958008CDB25 /* Frameworks */, D2FE947F2E78E958008CDB25 /* Frameworks */,
@@ -389,8 +391,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -410,9 +414,10 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
@@ -429,8 +434,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -450,9 +457,10 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
@@ -469,8 +477,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -481,7 +490,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -498,8 +507,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -510,7 +520,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -6,5 +6,7 @@
<true/> <true/>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.OpenClimb</string>
</array>
</dict>
</plist>

View File

@@ -32,6 +32,27 @@ class ClimbingDataManager: ObservableObject {
static let activeSession = "openclimb_active_session" static let activeSession = "openclimb_active_session"
} }
// Widget data models
private struct WidgetAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let timestamp: Date
let result: String
}
private struct WidgetSession: Codable {
let id: String
let gymId: String
let date: Date
let status: String
}
private struct WidgetGym: Codable {
let id: String
let name: String
}
init() { init() {
_ = ImageManager.shared _ = ImageManager.shared
loadAllData() loadAllData()
@@ -97,8 +118,13 @@ class ClimbingDataManager: ObservableObject {
private func saveGyms() { private func saveGyms() {
if let data = try? encoder.encode(gyms) { if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms) userDefaults.set(data, forKey: Keys.gyms)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.gyms) let widgetGyms = gyms.map { gym in
WidgetGym(id: gym.id.uuidString, name: gym.name)
}
if let widgetData = try? encoder.encode(widgetGyms) {
sharedUserDefaults?.set(widgetData, forKey: Keys.gyms)
}
} }
} }
@@ -113,16 +139,37 @@ class ClimbingDataManager: ObservableObject {
private func saveSessions() { private func saveSessions() {
if let data = try? encoder.encode(sessions) { if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions) userDefaults.set(data, forKey: Keys.sessions)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.sessions) let widgetSessions = sessions.map { session in
WidgetSession(
id: session.id.uuidString,
gymId: session.gymId.uuidString,
date: session.date,
status: session.status.rawValue
)
}
if let widgetData = try? encoder.encode(widgetSessions) {
sharedUserDefaults?.set(widgetData, forKey: Keys.sessions)
}
} }
} }
private func saveAttempts() { private func saveAttempts() {
if let data = try? encoder.encode(attempts) { if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts) userDefaults.set(data, forKey: Keys.attempts)
// Share with widget // Share with widget - convert to widget format
sharedUserDefaults?.set(data, forKey: Keys.attempts) let widgetAttempts = attempts.map { attempt in
WidgetAttempt(
id: attempt.id.uuidString,
sessionId: attempt.sessionId.uuidString,
problemId: attempt.problemId.uuidString,
timestamp: attempt.timestamp,
result: attempt.result.rawValue
)
}
if let widgetData = try? encoder.encode(widgetAttempts) {
sharedUserDefaults?.set(widgetData, forKey: Keys.attempts)
}
// Update widget timeline // Update widget timeline
updateWidgetTimeline() updateWidgetTimeline()
} }
@@ -1020,8 +1067,14 @@ extension ClimbingDataManager {
private func updateLiveActivityForActiveSession() { private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession, guard let activeSession = activeSession,
activeSession.status == .active, activeSession.status == .active,
let _ = gym(withId: activeSession.gymId) let gym = gym(withId: activeSession.gymId)
else { else {
print("⚠️ Live Activity update skipped - no active session or gym")
if let session = activeSession {
print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)")
print(" Gym ID: \(session.gymId)")
}
return return
} }
@@ -1040,6 +1093,16 @@ extension ClimbingDataManager {
elapsedInterval = 0 elapsedInterval = 0
} }
print("🔄 Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)")
print(" Completed problems: \(completedProblems)")
print(" Elapsed time: \(elapsedInterval) seconds")
print(
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })"
)
Task { Task {
await LiveActivityManager.shared.updateLiveActivity( await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsedInterval, elapsed: elapsedInterval,
@@ -1061,6 +1124,14 @@ extension ClimbingDataManager {
#endif #endif
} }
/// Debug function to manually trigger widget data update
func debugUpdateWidgetData() {
// Force save all data to widget
saveGyms()
saveSessions()
saveAttempts()
}
private func validateImportData(_ importData: ClimbDataExport) throws { private func validateImportData(_ importData: ClimbDataExport) throws {
if importData.gyms.isEmpty { if importData.gyms.isEmpty {
throw NSError( throw NSError(

View File

@@ -1,3 +1,4 @@
import PhotosUI
import SwiftUI import SwiftUI
struct AddAttemptView: View { struct AddAttemptView: View {
@@ -19,6 +20,8 @@ struct AddAttemptView: View {
@State private var newProblemGrade = "" @State private var newProblemGrade = ""
@State private var selectedClimbType: ClimbType = .boulder @State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale @State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
private var activeProblems: [Problem] { private var activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id) dataManager.activeProblems(forGym: gym.id)
@@ -126,6 +129,8 @@ struct AddAttemptView: View {
Button("Back") { Button("Back") {
showingCreateProblem = false showingCreateProblem = false
selectedPhotos = []
imageData = []
} }
.foregroundColor(.blue) .foregroundColor(.blue)
} }
@@ -209,6 +214,74 @@ struct AddAttemptView: View {
} }
} }
} }
Section("Photos (Optional)") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(.blue)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.onChange(of: selectedPhotos) { _, _ in
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: {
imageData.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
}
.offset(x: 8, y: -8)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
}
}
}
} }
@ViewBuilder @ViewBuilder
@@ -310,11 +383,20 @@ struct AddAttemptView: View {
let difficulty = DifficultyGrade( let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade) system: selectedDifficultySystem, grade: newProblemGrade)
// Save images and get paths
var imagePaths: [String] = []
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
}
}
let newProblem = Problem( let newProblem = Problem(
gymId: gym.id, gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName, name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty difficulty: difficulty,
imagePaths: imagePaths
) )
dataManager.addProblem(newProblem) dataManager.addProblem(newProblem)
@@ -347,8 +429,26 @@ struct AddAttemptView: View {
dataManager.addAttempt(attempt) dataManager.addAttempt(attempt)
} }
// Clear photo states after saving
selectedPhotos = []
imageData = []
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
struct ProblemSelectionRow: View { struct ProblemSelectionRow: View {
@@ -592,9 +692,43 @@ struct EditAttemptView: View {
@State private var notes: String @State private var notes: String
@State private var duration: Int @State private var duration: Int
@State private var restTime: Int @State private var restTime: Int
@State private var showingCreateProblem = false
// New problem creation state
@State private var newProblemName = ""
@State private var newProblemGrade = ""
@State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
private var availableProblems: [Problem] { private var availableProblems: [Problem] {
dataManager.problems.filter { $0.isActive } guard let session = dataManager.session(withId: attempt.sessionId) else {
return []
}
return dataManager.problems.filter { $0.isActive && $0.gymId == session.gymId }
}
private var gym: Gym? {
guard let session = dataManager.session(withId: attempt.sessionId) else {
return nil
}
return dataManager.gym(withId: session.gymId)
}
private var availableClimbTypes: [ClimbType] {
gym?.supportedClimbTypes ?? []
}
private var availableDifficultySystems: [DifficultySystem] {
guard let gym = gym else { return [] }
return DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
gym.difficultySystems.contains(system)
}
}
private var availableGrades: [String] {
selectedDifficultySystem.availableGrades
} }
init(attempt: Attempt) { init(attempt: Attempt) {
@@ -609,82 +743,13 @@ struct EditAttemptView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { Form {
Section("Select Problem") { if !showingCreateProblem {
if availableProblems.isEmpty { ProblemSelectionSection()
Text("No problems available") } else {
.foregroundColor(.secondary) CreateProblemSection()
} else {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 8
) {
ForEach(availableProblems, id: \.id) { problem in
ProblemSelectionCard(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
}
}
}
.padding(.vertical, 8)
}
} }
Section("Result") { AttemptDetailsSection()
ForEach(AttemptResult.allCases, id: \.self) { result in
HStack {
Text(result.displayName)
Spacer()
if selectedResult == result {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedResult = result
}
}
}
Section("Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
}
} }
.navigationTitle("Edit Attempt") .navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -699,30 +764,392 @@ struct EditAttemptView: View {
Button("Update") { Button("Update") {
updateAttempt() updateAttempt()
} }
.disabled(selectedProblem == nil) .disabled(!canSave)
} }
} }
} }
.onAppear { .onAppear {
selectedProblem = dataManager.problem(withId: attempt.problemId) selectedProblem = dataManager.problem(withId: attempt.problemId)
setupInitialValues()
}
.onChange(of: selectedClimbType) {
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded()
}
}
@ViewBuilder
private func ProblemSelectionSection() -> some View {
Section("Select Problem") {
if availableProblems.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("No active problems in this gym")
.foregroundColor(.secondary)
Button("Create New Problem") {
showingCreateProblem = true
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 8)
} else {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 8
) {
ForEach(availableProblems, id: \.id) { problem in
ProblemSelectionCard(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
}
}
}
.padding(.vertical, 8)
Button("Create New Problem") {
showingCreateProblem = true
}
.foregroundColor(.blue)
}
}
}
@ViewBuilder
private func CreateProblemSection() -> some View {
Section {
HStack {
Text("Create New Problem")
.font(.headline)
Spacer()
Button("Back") {
showingCreateProblem = false
selectedPhotos = []
imageData = []
}
.foregroundColor(.blue)
}
}
Section("Problem Details") {
TextField("Problem Name", text: $newProblemName)
}
Section("Climb Type") {
ForEach(availableClimbTypes, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
Section("Difficulty") {
VStack(alignment: .leading, spacing: 12) {
Text("Difficulty System")
.font(.subheadline)
.fontWeight(.medium)
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedDifficultySystem = system
}
}
}
if selectedDifficultySystem == .custom {
TextField("Grade (Required - numbers only)", text: $newProblemGrade)
.keyboardType(.numberPad)
.onChange(of: newProblemGrade) {
// Filter out non-numeric characters
newProblemGrade = newProblemGrade.filter { $0.isNumber }
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Grade (Required)")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(availableGrades, id: \.self) { grade in
Button(grade) {
newProblemGrade = grade
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(newProblemGrade == grade ? .blue : .gray)
}
}
.padding(.horizontal, 1)
}
}
}
}
Section("Photos (Optional)") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(.blue)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.onChange(of: selectedPhotos) { _, _ in
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
.overlay(alignment: .topTrailing) {
Button(action: {
imageData.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
}
.offset(x: 8, y: -8)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
}
}
}
}
@ViewBuilder
private func AttemptDetailsSection() -> some View {
Section("Attempt Result") {
ForEach(AttemptResult.allCases, id: \.self) { result in
HStack {
Text(result.displayName)
Spacer()
if selectedResult == result {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedResult = result
}
}
}
Section("Additional Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
}
}
private var canSave: Bool {
if showingCreateProblem {
return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} else {
return selectedProblem != nil
}
}
private func setupInitialValues() {
guard let gym = gym else { return }
// Auto-select climb type if there's only one available
if gym.supportedClimbTypes.count == 1 {
selectedClimbType = gym.supportedClimbTypes.first!
}
updateDifficultySystem()
}
private func updateDifficultySystem() {
let available = availableDifficultySystems
if !available.contains(selectedDifficultySystem) {
selectedDifficultySystem = available.first ?? .custom
}
if available.count == 1 {
selectedDifficultySystem = available.first!
}
}
private func resetGradeIfNeeded() {
let availableGrades = selectedDifficultySystem.availableGrades
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
newProblemGrade = ""
} }
} }
private func updateAttempt() { private func updateAttempt() {
guard selectedProblem != nil else { return } if showingCreateProblem {
guard let gym = gym else { return }
let updatedAttempt = attempt.updated( let difficulty = DifficultyGrade(
problemId: selectedProblem?.id, system: selectedDifficultySystem, grade: newProblemGrade)
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold, // Save images and get paths
notes: notes.isEmpty ? nil : notes, var imagePaths: [String] = []
duration: duration > 0 ? duration : nil, for data in imageData {
restTime: restTime > 0 ? restTime : nil if let relativePath = ImageManager.shared.saveImageData(data) {
) imagePaths.append(relativePath)
}
}
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty,
imagePaths: imagePaths
)
dataManager.addProblem(newProblem)
let updatedAttempt = attempt.updated(
problemId: newProblem.id,
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration > 0 ? duration : nil,
restTime: restTime > 0 ? restTime : nil
)
dataManager.updateAttempt(updatedAttempt)
} else {
guard selectedProblem != nil else { return }
let updatedAttempt = attempt.updated(
problemId: selectedProblem?.id,
result: selectedResult,
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes,
duration: duration > 0 ? duration : nil,
restTime: restTime > 0 ? restTime : nil
)
dataManager.updateAttempt(updatedAttempt)
}
// Clear photo states after saving
selectedPhotos = []
imageData = []
dataManager.updateAttempt(updatedAttempt)
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
#Preview { #Preview {
@@ -769,6 +1196,7 @@ struct ProblemSelectionImageView: View {
ProgressView() ProgressView()
.scaleEffect(0.8) .scaleEffect(0.8)
} }
} }
} }
.onAppear { .onAppear {

View File

@@ -1,4 +1,3 @@
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
@@ -61,11 +60,11 @@ struct AddEditProblemView: View {
Form { Form {
GymSelectionSection() GymSelectionSection()
BasicInfoSection() BasicInfoSection()
PhotosSection()
ClimbTypeSection() ClimbTypeSection()
DifficultySection() DifficultySection()
LocationAndSetterSection() LocationAndSetterSection()
TagsSection() TagsSection()
PhotosSection()
AdditionalInfoSection() AdditionalInfoSection()
} }
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem") .navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
@@ -304,18 +303,30 @@ struct AddEditProblemView: View {
@ViewBuilder @ViewBuilder
private func PhotosSection() -> some View { private func PhotosSection() -> some View {
Section("Photos") { Section("Photos (Optional)") {
PhotosPicker( PhotosPicker(
selection: $selectedPhotos, selection: $selectedPhotos,
maxSelectionCount: 5, maxSelectionCount: 5,
matching: .images matching: .images
) { ) {
HStack { HStack {
Image(systemName: "photo.on.rectangle.angled") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
Text("Add Photos (\(imageData.count)/5)") .font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(.blue)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer() Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
} }
.padding(.vertical, 4)
} }
if !imageData.isEmpty { if !imageData.isEmpty {

View File

@@ -104,13 +104,14 @@ struct StatCard: View {
struct ProgressChartSection: View { struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale @State private var selectedSystem: DifficultySystem = .vScale
@State private var showAllTime: Bool = true
private var progressData: [ProgressDataPoint] { private var gradeCountData: [GradeCount] {
calculateProgressOverTime() calculateGradeCounts()
} }
private var usedSystems: [DifficultySystem] { private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(progressData.map { $0.difficultySystem }) let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
return uniqueSystems.sorted { return uniqueSystems.sorted {
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom] let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
let firstIndex = order.firstIndex(of: $0) ?? order.count let firstIndex = order.firstIndex(of: $0) ?? order.count
@@ -121,13 +122,50 @@ struct ProgressChartSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Grade Distribution")
.font(.title2)
.fontWeight(.bold)
// Toggles section
HStack { HStack {
Text("Progress Over Time") // Time period toggle
.font(.title2) HStack(spacing: 8) {
.fontWeight(.bold) Button(action: {
showAllTime = true
}) {
Text("All Time")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(showAllTime ? .white : .blue)
}
Button(action: {
showAllTime = false
}) {
Text("7 Days")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(!showAllTime ? .white : .blue)
}
}
Spacer() Spacer()
// Scale selector (only show if multiple systems)
if usedSystems.count > 1 { if usedSystems.count > 1 {
Menu { Menu {
ForEach(usedSystems, id: \.self) { system in ForEach(usedSystems, id: \.self) { system in
@@ -164,24 +202,22 @@ struct ProgressChartSection: View {
} }
} }
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem } let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty { if !filteredData.isEmpty {
LineChartView(data: filteredData, selectedSystem: selectedSystem) BarChartView(data: filteredData)
.frame(height: 200) .frame(height: 200)
Text( Text("Successful climbs by grade")
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session" .font(.caption)
) .foregroundColor(.secondary)
.font(.caption)
.foregroundColor(.secondary)
} else { } else {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis") Image(systemName: "chart.bar")
.font(.title) .font(.title)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No progress data available for \(selectedSystem.displayName) system") Text("No data available for \(selectedSystem.displayName) system")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -201,38 +237,125 @@ struct ProgressChartSection: View {
} }
} }
private func calculateProgressOverTime() -> [ProgressDataPoint] { private func calculateGradeCounts() -> [GradeCount] {
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
let problems = dataManager.problems let problems = dataManager.problems
let attempts = dataManager.attempts let attempts = dataManager.attempts
return sessions.compactMap { session in // Filter attempts by time period
let sessionAttempts = attempts.filter { $0.sessionId == session.id } let filteredAttempts: [Attempt]
let attemptedProblemIds = sessionAttempts.map { $0.problemId } if showAllTime {
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } filteredAttempts = attempts.filter { $0.result.isSuccessful }
} else {
let sevenDaysAgo =
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
filteredAttempts = attempts.filter {
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
}
}
// Group problems by difficulty system // Get attempted problems
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Create data points for each system used in this session // Group by difficulty system and grade
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in var gradeCounts: [String: GradeCount] = [:]
guard
let highestGradeProblem = systemProblems.max(by: {
$0.difficulty.numericValue < $1.difficulty.numericValue
})
else {
return nil
}
return ProgressDataPoint( for problem in attemptedProblems {
date: session.date, let successfulAttemptsForProblem = filteredAttempts.filter {
maxGrade: highestGradeProblem.difficulty.grade, $0.problemId == problem.id
maxGradeNumeric: highestGradeProblem.difficulty.numericValue, }
climbType: highestGradeProblem.climbType, let count = successfulAttemptsForProblem.count
difficultySystem: system
let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
if let existing = gradeCounts[key] {
gradeCounts[key] = GradeCount(
grade: existing.grade,
count: existing.count + count,
gradeNumeric: existing.gradeNumeric,
difficultySystem: existing.difficultySystem
)
} else {
gradeCounts[key] = GradeCount(
grade: problem.difficulty.grade,
count: count,
gradeNumeric: problem.difficulty.numericValue,
difficultySystem: problem.difficulty.system
) )
} }
}.flatMap { $0 } }
return Array(gradeCounts.values)
}
}
struct GradeCount {
let grade: String
let count: Int
let gradeNumeric: Int
let difficultySystem: DifficultySystem
}
struct BarChartView: View {
let data: [GradeCount]
private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
}
private var maxCount: Int {
data.map { $0.count }.max() ?? 1
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
if sortedData.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
VStack(alignment: .leading) {
// Chart area
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
VStack(spacing: 4) {
// Bar
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(
width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
* chartHeight * 0.8
)
.overlay(
Text("\(gradeCount.count)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.opacity(gradeCount.count > 0 ? 1 : 0)
)
// Grade label
Text(gradeCount.grade)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.frame(height: chartHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
} }
} }
@@ -253,7 +376,7 @@ struct FavoriteGymSection: View {
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Image(systemName: "location.fill") Image(systemName: "location.fill")
.font(.title2) .font(.title2)
@@ -380,139 +503,6 @@ struct RecentActivitySection: View {
} }
} }
struct LineChartView: View {
let data: [ProgressDataPoint]
let selectedSystem: DifficultySystem
private var uniqueGrades: [String] {
if selectedSystem == .custom {
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
return (Int(grade1) ?? 0) > (Int(grade2) ?? 0)
}
} else {
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
let grade1Data = data.first(where: { $0.maxGrade == grade1 })
let grade2Data = data.first(where: { $0.maxGrade == grade2 })
return (grade1Data?.maxGradeNumeric ?? 0)
> (grade2Data?.maxGradeNumeric ?? 0)
}
}
}
private var minGrade: Int {
data.map { $0.maxGradeNumeric }.min() ?? 0
}
private var maxGrade: Int {
data.map { $0.maxGradeNumeric }.max() ?? 1
}
private var gradeRange: Int {
max(maxGrade - minGrade, 1)
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
if data.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
HStack {
// Y-axis labels
VStack {
ForEach(0..<min(5, uniqueGrades.count), id: \.self) { i in
let gradeLabel = i < uniqueGrades.count ? uniqueGrades[i] : ""
Text(gradeLabel)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 30, alignment: .trailing)
if i < min(4, uniqueGrades.count - 1) {
Spacer()
}
}
}
.frame(height: chartHeight)
// Chart area
ZStack {
// Grid lines
ForEach(0..<5) { i in
let y = CGFloat(i) * chartHeight / 4
Rectangle()
.fill(.gray.opacity(0.2))
.frame(height: 0.5)
.offset(y: y - chartHeight / 2)
}
// Line chart
if data.count > 1 {
Path { path in
for (index, point) in data.enumerated() {
let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1)
let normalizedY =
CGFloat(point.maxGradeNumeric - minGrade)
/ CGFloat(gradeRange)
let y = chartHeight - (normalizedY * chartHeight)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(.blue, lineWidth: 2)
}
// Data points
ForEach(data.indices, id: \.self) { index in
let point = data[index]
let x =
data.count == 1
? chartWidth / 2
: CGFloat(index) * chartWidth / CGFloat(data.count - 1)
let normalizedY =
CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange)
let y = chartHeight - (normalizedY * chartHeight)
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
.position(x: x, y: y)
.overlay(
Circle()
.stroke(.white, lineWidth: 2)
.frame(width: 8, height: 8)
.position(x: x, y: y)
)
}
}
.frame(width: chartWidth, height: chartHeight)
}
}
}
.padding()
}
}
struct ProgressDataPoint {
let date: Date
let maxGrade: String
let maxGradeNumeric: Int
let climbType: ClimbType
let difficultySystem: DifficultySystem
}
#Preview { #Preview {
AnalyticsView() AnalyticsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -138,13 +138,12 @@ struct ActiveSessionBanner: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
navigateToDetail = true navigateToDetail = true
} }
Spacer()
Button(action: { Button(action: {
dataManager.endSession(session.id) dataManager.endSession(session.id)
}) { }) {
@@ -155,6 +154,7 @@ struct ActiveSessionBanner: View {
.background(Color.red) .background(Color.red)
.clipShape(Circle()) .clipShape(Circle())
} }
.buttonStyle(PlainButtonStyle())
} }
.padding() .padding()
.background( .background(

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.OpenClimb</string>
</array>
</dict>
</plist>