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
- Android:1.4.2
- iOS: 1.0.0
- iOS: 1.0.1
## Download
@@ -16,7 +16,7 @@ For Android do one of the following:
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

View File

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

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,78 +10,76 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint
import com.atridad.openclimb.ui.components.LineChart
import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
fun AnalyticsScreen(
viewModel: ClimbViewModel
) {
fun AnalyticsScreen(viewModel: ClimbViewModel) {
val sessions by viewModel.sessions.collectAsState()
val problems by viewModel.problems.collectAsState()
val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
text = "Analytics",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
// Overall Stats
item {
OverallStatsCard(
totalSessions = sessions.size,
totalProblems = problems.size,
totalAttempts = attempts.size,
totalGyms = gyms.size
totalSessions = sessions.size,
totalProblems = problems.size,
totalAttempts = attempts.size,
totalGyms = gyms.size
)
}
// Progress Chart
// Grade Distribution Chart
item {
val progressData = calculateProgressOverTime(sessions, problems, attempts)
ProgressChartCard(progressData = progressData, problems = problems)
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
}
// Favorite Gym
item {
val favoriteGym = sessions
.groupBy { it.gymId }
.maxByOrNull { it.value.size }
?.let { (gymId, sessions) ->
gyms.find { it.id == gymId }?.name to sessions.size
}
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, sessions) ->
gyms.find { it.id == gymId }?.name to sessions.size
}
FavoriteGymCard(
gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0
gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0
)
}
// Recent Activity
item {
val recentSessions = sessions.take(5)
@@ -91,31 +89,20 @@ fun AnalyticsScreen(
}
@Composable
fun OverallStatsCard(
totalSessions: Int,
totalProblems: Int,
totalAttempts: Int,
totalGyms: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Overall Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
text = "Overall Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(label = "Sessions", value = totalSessions.toString())
StatItem(label = "Problems", value = totalProblems.toString())
@@ -128,178 +115,241 @@ fun OverallStatsCard(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgressChartCard(
progressData: List<ProgressDataPoint>,
problems: List<com.atridad.openclimb.data.model.Problem>,
) {
// Find all grading systems that have been used in the progress data
val usedSystems = remember(progressData) {
progressData.map { it.difficultySystem }.distinct()
}
var selectedSystem by remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionDataPoint>) {
// Find all grading systems that have been used in the data
val usedSystems =
remember(gradeDistributionData) {
gradeDistributionData.map { it.difficultySystem }.distinct()
}
var selectedSystem by
remember(usedSystems) {
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
}
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Progress Over Time",
var showAllTime by remember { mutableStateOf(true) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Grade Distribution",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
// Toggles section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Time period toggle
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// All Time button
FilterChip(
onClick = { showAllTime = true },
label = {
Text("All Time", style = MaterialTheme.typography.bodySmall)
},
selected = showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
// 7 Days button
FilterChip(
onClick = { showAllTime = false },
label = { Text("7 Days", style = MaterialTheme.typography.bodySmall) },
selected = !showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
// Scale selector dropdown
if (usedSystems.size > 1) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = when (selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
},
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(when (system) {
value =
when (selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
})
},
onClick = {
selectedSystem = system
expanded = false
}
},
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier =
Modifier.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable,
enabled = true
)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(
when (system) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}
)
},
onClick = {
selectedSystem = system
expanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale
val filteredProgressData = remember(progressData, selectedSystem) {
progressData.filter { it.difficultySystem == selectedSystem }
}
if (filteredProgressData.isNotEmpty()) {
val chartData = remember(filteredProgressData) {
// Convert progress data to chart data points ordered by session
filteredProgressData
.sortedBy { it.date }
.mapIndexed { index, p ->
ChartDataPoint(
x = (index + 1).toFloat(),
y = p.maxGradeNumeric.toFloat(),
label = "Session ${index + 1}"
)
// Filter grade distribution data by selected scale and time period
val filteredGradeData =
remember(gradeDistributionData, selectedSystem, showAllTime) {
val systemFiltered =
gradeDistributionData.filter {
it.difficultySystem == selectedSystem
}
if (showAllTime) {
systemFiltered
} else {
// Filter for last 7 days
val sevenDaysAgo = LocalDateTime.now().minusDays(7)
systemFiltered.filter { dataPoint ->
try {
val attemptDate =
LocalDateTime.parse(
dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (e: Exception) {
// If date parsing fails, include the data point
true
}
}
}
}
LineChart(
data = chartData,
modifier = Modifier.fillMaxWidth().height(220.dp),
xAxisFormatter = { value ->
"S${value.toInt()}" // S1, S2, S3, etc.
},
yAxisFormatter = { value ->
numericToGrade(selectedSystem, value.toInt())
}
)
if (filteredGradeData.isNotEmpty()) {
// Group by grade and sum counts
val gradeGroups =
filteredGradeData
.groupBy { it.grade }
.mapValues { (_, dataPoints) -> dataPoints.sumOf { it.count } }
.map { (grade, count) ->
val firstDataPoint =
filteredGradeData.first { it.grade == grade }
BarChartDataPoint(
label = grade,
value = count,
gradeNumeric = firstDataPoint.gradeNumeric
)
}
BarChart(data = gradeGroups, modifier = Modifier.fillMaxWidth().height(220.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "X: session number, Y: max ${when(selectedSystem) {
text =
"Successful climbs by ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade"
}} achieved",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
}}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No progress data available for ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}} system",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
modifier = Modifier.fillMaxWidth().height(220.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "No data",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No data available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
if (showAllTime)
"Complete some climbs to see your grade distribution!"
else "No climbs in the last 7 days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
}
@Composable
fun FavoriteGymCard(
gymName: String,
sessionCount: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
fun FavoriteGymCard(gymName: String, sessionCount: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = gymName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium
text = gymName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium
)
if (sessionCount > 0) {
Text(
text = "$sessionCount sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = "$sessionCount sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -307,74 +357,92 @@ fun FavoriteGymCard(
}
@Composable
fun RecentActivityCard(
recentSessions: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
fun RecentActivityCard(recentSessions: Int) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
text = "Recent Activity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (recentSessions > 0) {
"You've had $recentSessions recent sessions"
} else {
"No recent activity"
},
style = MaterialTheme.typography.bodyMedium
text =
if (recentSessions > 0) {
"You've had $recentSessions recent sessions"
} else {
"No recent activity"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
data class ProgressDataPoint(
val date: String,
val maxGrade: String,
val maxGradeNumeric: Int,
val climbType: ClimbType,
val difficultySystem: DifficultySystem
data class GradeDistributionDataPoint(
val date: String,
val grade: String,
val gradeNumeric: Int,
val count: Int,
val climbType: ClimbType,
val difficultySystem: DifficultySystem
)
fun calculateProgressOverTime(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> {
fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList()
}
val sessionProgress = sessions.mapNotNull { session ->
val sessionAttempts = attempts.filter { it.sessionId == session.id }
if (sessionAttempts.isEmpty()) return@mapNotNull null
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in attemptedProblemIds }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem ->
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade)
}
if (highestGradeProblem != null) {
ProgressDataPoint(
date = session.date,
maxGrade = highestGradeProblem.difficulty.grade,
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
climbType = highestGradeProblem.climbType,
difficultySystem = highestGradeProblem.difficulty.system
)
} else null
// Get all successful attempts
val successfulAttempts =
attempts.filter {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
if (successfulAttempts.isEmpty()) {
return emptyList()
}
return sessionProgress.sortedBy { it.date }
// Map attempts to problems and create grade distribution data
val gradeDistribution = mutableMapOf<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 {
@@ -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]
agp = "8.12.2"
kotlin = "2.2.10"
agp = "8.12.3"
kotlin = "2.2.20"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -9,12 +9,12 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.3"
activityCompose = "1.10.1"
composeBom = "2025.08.01"
room = "2.7.2"
navigation = "2.9.3"
viewmodel = "2.9.3"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.09.01"
room = "2.8.1"
navigation = "2.9.5"
viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
@@ -39,6 +39,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Room Database
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
@@ -59,7 +60,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version = "1.13.8" }
mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
@@ -72,4 +73,3 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@@ -40,6 +40,7 @@
/* Begin PBXFileReference section */
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; };
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; };
@@ -107,6 +108,7 @@
D24C195F2E75002A0045894C = {
isa = PBXGroup;
children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2FE947F2E78E958008CDB25 /* Frameworks */,
@@ -389,8 +391,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -410,9 +414,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -429,8 +434,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -450,9 +457,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -469,8 +477,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -481,7 +490,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -498,8 +507,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -510,7 +520,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.0;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -6,5 +6,7 @@
<true/>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string>
</dict>
</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"
}
// 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() {
_ = ImageManager.shared
loadAllData()
@@ -97,8 +118,13 @@ class ClimbingDataManager: ObservableObject {
private func saveGyms() {
if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.gyms)
// Share with widget - convert to widget format
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() {
if let data = try? encoder.encode(sessions) {
userDefaults.set(data, forKey: Keys.sessions)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.sessions)
// Share with widget - convert to widget format
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() {
if let data = try? encoder.encode(attempts) {
userDefaults.set(data, forKey: Keys.attempts)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.attempts)
// Share with widget - convert to widget format
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
updateWidgetTimeline()
}
@@ -1020,8 +1067,14 @@ extension ClimbingDataManager {
private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession,
activeSession.status == .active,
let _ = gym(withId: activeSession.gymId)
let gym = gym(withId: activeSession.gymId)
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
}
@@ -1040,6 +1093,16 @@ extension ClimbingDataManager {
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 {
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsedInterval,
@@ -1061,6 +1124,14 @@ extension ClimbingDataManager {
#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 {
if importData.gyms.isEmpty {
throw NSError(

View File

@@ -1,3 +1,4 @@
import PhotosUI
import SwiftUI
struct AddAttemptView: View {
@@ -19,6 +20,8 @@ struct AddAttemptView: View {
@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 activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id)
@@ -126,6 +129,8 @@ struct AddAttemptView: View {
Button("Back") {
showingCreateProblem = false
selectedPhotos = []
imageData = []
}
.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
@@ -310,11 +383,20 @@ struct AddAttemptView: View {
let difficulty = DifficultyGrade(
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(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty
difficulty: difficulty,
imagePaths: imagePaths
)
dataManager.addProblem(newProblem)
@@ -347,8 +429,26 @@ struct AddAttemptView: View {
dataManager.addAttempt(attempt)
}
// Clear photo states after saving
selectedPhotos = []
imageData = []
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 {
@@ -592,9 +692,43 @@ struct EditAttemptView: View {
@State private var notes: String
@State private var duration: 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] {
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) {
@@ -609,82 +743,13 @@ struct EditAttemptView: View {
var body: some View {
NavigationView {
Form {
Section("Select Problem") {
if availableProblems.isEmpty {
Text("No problems available")
.foregroundColor(.secondary)
} 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)
}
if !showingCreateProblem {
ProblemSelectionSection()
} else {
CreateProblemSection()
}
Section("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("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)
}
}
AttemptDetailsSection()
}
.navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline)
@@ -699,30 +764,392 @@ struct EditAttemptView: View {
Button("Update") {
updateAttempt()
}
.disabled(selectedProblem == nil)
.disabled(!canSave)
}
}
}
.onAppear {
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() {
guard selectedProblem != nil else { return }
if showingCreateProblem {
guard let gym = gym 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
)
let difficulty = DifficultyGrade(
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(
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()
}
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 {
@@ -769,6 +1196,7 @@ struct ProblemSelectionImageView: View {
ProgressView()
.scaleEffect(0.8)
}
}
}
.onAppear {

View File

@@ -1,4 +1,3 @@
import PhotosUI
import SwiftUI
@@ -61,11 +60,11 @@ struct AddEditProblemView: View {
Form {
GymSelectionSection()
BasicInfoSection()
PhotosSection()
ClimbTypeSection()
DifficultySection()
LocationAndSetterSection()
TagsSection()
PhotosSection()
AdditionalInfoSection()
}
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
@@ -304,18 +303,30 @@ struct AddEditProblemView: View {
@ViewBuilder
private func PhotosSection() -> some View {
Section("Photos") {
Section("Photos (Optional)") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "photo.on.rectangle.angled")
Image(systemName: "camera.fill")
.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()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
if !imageData.isEmpty {

View File

@@ -104,13 +104,14 @@ struct StatCard: View {
struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale
@State private var showAllTime: Bool = true
private var progressData: [ProgressDataPoint] {
calculateProgressOverTime()
private var gradeCountData: [GradeCount] {
calculateGradeCounts()
}
private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(progressData.map { $0.difficultySystem })
let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
return uniqueSystems.sorted {
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
let firstIndex = order.firstIndex(of: $0) ?? order.count
@@ -121,13 +122,50 @@ struct ProgressChartSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Grade Distribution")
.font(.title2)
.fontWeight(.bold)
// Toggles section
HStack {
Text("Progress Over Time")
.font(.title2)
.fontWeight(.bold)
// Time period toggle
HStack(spacing: 8) {
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()
// Scale selector (only show if multiple systems)
if usedSystems.count > 1 {
Menu {
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 {
LineChartView(data: filteredData, selectedSystem: selectedSystem)
BarChartView(data: filteredData)
.frame(height: 200)
Text(
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session"
)
.font(.caption)
.foregroundColor(.secondary)
Text("Successful climbs by grade")
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
Image(systemName: "chart.bar")
.font(.title)
.foregroundColor(.secondary)
Text("No progress data available for \(selectedSystem.displayName) system")
Text("No data available for \(selectedSystem.displayName) system")
.font(.subheadline)
.foregroundColor(.secondary)
}
@@ -201,38 +237,125 @@ struct ProgressChartSection: View {
}
}
private func calculateProgressOverTime() -> [ProgressDataPoint] {
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
private func calculateGradeCounts() -> [GradeCount] {
let problems = dataManager.problems
let attempts = dataManager.attempts
return sessions.compactMap { session in
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Filter attempts by time period
let filteredAttempts: [Attempt]
if showAllTime {
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
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system }
// Get attempted problems
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Create data points for each system used in this session
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in
guard
let highestGradeProblem = systemProblems.max(by: {
$0.difficulty.numericValue < $1.difficulty.numericValue
})
else {
return nil
}
// Group by difficulty system and grade
var gradeCounts: [String: GradeCount] = [:]
return ProgressDataPoint(
date: session.date,
maxGrade: highestGradeProblem.difficulty.grade,
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
climbType: highestGradeProblem.climbType,
difficultySystem: system
for problem in attemptedProblems {
let successfulAttemptsForProblem = filteredAttempts.filter {
$0.problemId == problem.id
}
let count = successfulAttemptsForProblem.count
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 {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
HStack {
Image(systemName: "location.fill")
.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 {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)

View File

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