Compare commits

..

19 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
6172074509 ??? 2025-09-20 12:03:46 -06:00
0235b5d506 iOS Release - 1.0.1 2025-09-20 12:03:37 -06:00
7c18b56674 ??? 2025-09-16 09:57:25 -06:00
cccdc2dd66 Build 3 - Remove debug settings from dev 2025-09-16 09:57:04 -06:00
62703cf2eb ??? 2025-09-16 00:36:43 -06:00
2c0ae23417 Remove iPad compat 2025-09-16 00:36:36 -06:00
87dcd08189 ??? 2025-09-15 23:34:42 -06:00
f3dabbd3aa Fixed swipe actions and more widgets 2025-09-15 23:34:33 -06:00
e4c6440758 Fixed Widget stats 2025-09-15 23:27:21 -06:00
b478f05260 ??? 2025-09-15 21:01:18 -06:00
afd954785a Proper 1.0 release for iOS. Pending App Store submission. 2025-09-15 21:01:02 -06:00
d95c45abbb Update README.md 2025-09-15 05:11:20 +00:00
41 changed files with 3277 additions and 844 deletions

View File

@@ -4,8 +4,8 @@ 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,78 +10,76 @@ 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
) )
} }
} }
// 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
) )
} }
// Recent Activity // Recent Activity
item { item {
val recentSessions = sessions.take(5) val recentSessions = sessions.take(5)
@@ -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,178 +115,241 @@ 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 remember(usedSystems) { mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
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(
modifier = Modifier.fillMaxWidth() Card(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column( Text(
modifier = Modifier text = "Grade Distribution",
.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
}
) )
} }
} }
} }
} }
} }
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 {
if (filteredProgressData.isNotEmpty()) { it.difficultySystem == selectedSystem
val chartData = remember(filteredProgressData) { }
// Convert progress data to chart data points ordered by session
filteredProgressData if (showAllTime) {
.sortedBy { it.date } systemFiltered
.mapIndexed { index, p -> } else {
ChartDataPoint( // Filter for last 7 days
x = (index + 1).toFloat(), val sevenDaysAgo = LocalDateTime.now().minusDays(7)
y = p.maxGradeNumeric.toFloat(), systemFiltered.filter { dataPoint ->
label = "Session ${index + 1}" 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)) 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

@@ -0,0 +1,143 @@
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct ClimbingActivityWidgetBundle: WidgetBundle {
var body: some Widget {
ClimbingActivityWidget()
}
}
struct ClimbingActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: SessionActivityAttributes.self) { context in
// Lock Screen/Banner UI goes here
LiveActivityView(context: context)
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here
DynamicIslandExpandedRegion(.leading) {
Text("🧗‍♂️")
.font(.title2)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.attributes.gymName)
.lineLimit(1)
.font(.caption)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Label("\(context.state.totalAttempts)", systemImage: "hand.raised.fill")
Spacer()
Label("\(context.state.completedProblems)", systemImage: "checkmark.circle")
Spacer()
TimerView(start: context.attributes.startTime)
}
.font(.caption)
}
} compactLeading: {
Text("🧗‍♂️")
} compactTrailing: {
Text("\(context.state.totalAttempts)")
.monospacedDigit()
} minimal: {
Text("🧗‍♂️")
}
}
}
}
struct LiveActivityView: View {
let context: ActivityViewContext<SessionActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("🧗‍♂️ \(context.attributes.gymName)")
.font(.headline)
.lineLimit(1)
Spacer()
TimerView(start: context.attributes.startTime)
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 2) {
Text("Attempts")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
Text("\(context.state.totalAttempts)")
.font(.title3)
.fontWeight(.semibold)
}
}
VStack(alignment: .leading, spacing: 2) {
Text("Completed")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("\(context.state.completedProblems)")
.font(.title3)
.fontWeight(.semibold)
}
}
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct TimerView: View {
let start: Date
@State private var now = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text(formatElapsed(from: start, to: now))
.monospacedDigit()
.onReceive(timer) { time in
now = time
}
}
private func formatElapsed(from: Date, to: Date) -> String {
let interval = Int(to.timeIntervalSince(from))
let hours = interval / 3600
let minutes = (interval % 3600) / 60
let seconds = interval % 60
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%02d:%02d", minutes, seconds)
}
}
}
// Preview for development
#Preview("Live Activity", as: .content, using: SessionActivityAttributes.preview) {
ClimbingActivityWidget()
} contentStates: {
SessionActivityAttributes.ContentState(elapsed: 1234, totalAttempts: 13, completedProblems: 4)
SessionActivityAttributes.ContentState(elapsed: 2400, totalAttempts: 25, completedProblems: 8)
}
extension SessionActivityAttributes {
static var preview: SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
}
}

View File

@@ -0,0 +1,31 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ClimbingActivityWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,50 @@
import ActivityKit
import Foundation
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private var currentActivity: Activity<SessionActivityAttributes>?
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
await endLiveActivity()
let attributes = SessionActivityAttributes(
gymName: gymName, startTime: session.startTime ?? session.date)
let initialContentState = SessionActivityAttributes.ContentState(
elapsed: 0,
totalAttempts: 0,
completedProblems: 0
)
do {
currentActivity = try Activity<SessionActivityAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil
)
} catch {
print("Failed to start live activity: \(error)")
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity else { return }
let updatedContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
guard let currentActivity else { return }
await currentActivity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
}
}

View File

@@ -6,8 +6,45 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
D2FE94822E78E95C008CDB25 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE94802E78E958008CDB25 /* ActivityKit.framework */; };
D2FE948D2E78FEE0008CDB25 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */; };
D2FE948F2E78FEE0008CDB25 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */; };
D2FE94A02E78FEE1008CDB25 /* SessionStatusLiveExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D2FE94A82E78FFB7008CDB25 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE94802E78E958008CDB25 /* ActivityKit.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D24C19602E75002A0045894C /* Project object */;
proxyType = 1;
remoteGlobalIDString = D2FE948A2E78FEE0008CDB25;
remoteInfo = SessionStatusLiveExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
D2FE94A52E78FEE1008CDB25 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D2FE94A02E78FEE1008CDB25 /* SessionStatusLiveExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* 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; };
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; };
D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -18,6 +55,13 @@
); );
target = D24C19672E75002A0045894C /* OpenClimb */; target = D24C19672E75002A0045894C /* OpenClimb */;
}; };
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -29,6 +73,14 @@
path = OpenClimb; path = OpenClimb;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */,
);
path = SessionStatusLive;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -36,6 +88,17 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D2FE94822E78E95C008CDB25 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94882E78FEE0008CDB25 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D2FE94A82E78FFB7008CDB25 /* ActivityKit.framework in Frameworks */,
D2FE948F2E78FEE0008CDB25 /* SwiftUI.framework in Frameworks */,
D2FE948D2E78FEE0008CDB25 /* WidgetKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -45,7 +108,10 @@
D24C195F2E75002A0045894C = { D24C195F2E75002A0045894C = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2FE947F2E78E958008CDB25 /* Frameworks */,
D24C19692E75002A0045894C /* Products */, D24C19692E75002A0045894C /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -54,10 +120,21 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D24C19682E75002A0045894C /* OpenClimb.app */, D24C19682E75002A0045894C /* OpenClimb.app */,
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D2FE947F2E78E958008CDB25 /* Frameworks */ = {
isa = PBXGroup;
children = (
D2FE94802E78E958008CDB25 /* ActivityKit.framework */,
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */,
D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -68,10 +145,12 @@
D24C19642E75002A0045894C /* Sources */, D24C19642E75002A0045894C /* Sources */,
D24C19652E75002A0045894C /* Frameworks */, D24C19652E75002A0045894C /* Frameworks */,
D24C19662E75002A0045894C /* Resources */, D24C19662E75002A0045894C /* Resources */,
D2FE94A52E78FEE1008CDB25 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
D24C196A2E75002A0045894C /* OpenClimb */, D24C196A2E75002A0045894C /* OpenClimb */,
@@ -83,6 +162,28 @@
productReference = D24C19682E75002A0045894C /* OpenClimb.app */; productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */;
buildPhases = (
D2FE94872E78FEE0008CDB25 /* Sources */,
D2FE94882E78FEE0008CDB25 /* Frameworks */,
D2FE94892E78FEE0008CDB25 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
);
name = SessionStatusLiveExtension;
packageProductDependencies = (
);
productName = SessionStatusLiveExtension;
productReference = D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -96,6 +197,9 @@
D24C19672E75002A0045894C = { D24C19672E75002A0045894C = {
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
}; };
D2FE948A2E78FEE0008CDB25 = {
CreatedOnToolsVersion = 26.0;
};
}; };
}; };
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */; buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
@@ -113,6 +217,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
D24C19672E75002A0045894C /* OpenClimb */, D24C19672E75002A0045894C /* OpenClimb */,
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -125,6 +230,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2FE94892E78FEE0008CDB25 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -135,8 +247,23 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2FE94872E78FEE0008CDB25 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
targetProxy = D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
D24C19712E75002A0045894C /* Debug */ = { D24C19712E75002A0045894C /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -264,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 = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -285,15 +414,18 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; 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";
SUPPORTS_MACCATALYST = NO;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Debug; name = Debug;
}; };
@@ -302,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 = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -323,14 +457,77 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.0; 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";
SUPPORTS_MACCATALYST = NO;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
D2FE94A22E78FEE1008CDB25 /* Debug */ = {
isa = XCBuildConfiguration;
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 = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D2FE94A32E78FEE1008CDB25 /* Release */ = {
isa = XCBuildConfiguration;
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 = 7;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
@@ -356,6 +553,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2FE94A22E78FEE1008CDB25 /* Debug */,
D2FE94A32E78FEE1008CDB25 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = D24C19602E75002A0045894C /* Project object */; rootObject = D24C19602E75002A0045894C /* Project object */;

View File

@@ -4,4 +4,10 @@
<FileRef <FileRef
location = "self:"> location = "self:">
</FileRef> </FileRef>
<FileRef
location = "group:LiveActivityManager.swift">
</FileRef>
<FileRef
location = "group:SessionLiveActivityWidget.swift">
</FileRef>
</Workspace> </Workspace>

View File

@@ -0,0 +1,5 @@
<?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/>
</plist>

View File

@@ -0,0 +1,16 @@
<?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>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CompilationCachingSetting</key>
<string>Default</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

View File

@@ -9,6 +9,11 @@
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager() @StateObject private var dataManager = ClimbingDataManager()
@State private var selectedTab = 0 @State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -43,6 +43,11 @@ struct ContentView: View {
.tag(4) .tag(4)
} }
.environmentObject(dataManager) .environmentObject(dataManager)
.onChange(of: scenePhase) {
if scenePhase == .active {
dataManager.onAppBecomeActive()
}
}
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let message = dataManager.successMessage { if let message = dataManager.successMessage {
SuccessMessageView(message: message) SuccessMessageView(message: message)

View File

@@ -4,5 +4,9 @@
<dict> <dict>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <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> </dict>
</plist> </plist>

View File

@@ -0,0 +1,19 @@
import ActivityKit
import Foundation
struct SessionActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
extension SessionActivityAttributes {
static var preview: SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
}
}

View File

@@ -1,4 +1,3 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -493,13 +492,14 @@ struct Attempt: Identifiable, Codable, Hashable {
} }
func updated( func updated(
result: AttemptResult? = nil, highestHold: String? = nil, notes: String? = nil, problemId: UUID? = nil, result: AttemptResult? = nil, highestHold: String? = nil,
notes: String? = nil,
duration: Int? = nil, restTime: Int? = nil duration: Int? = nil, restTime: Int? = nil
) -> Attempt { ) -> Attempt {
return Attempt( return Attempt(
id: self.id, id: self.id,
sessionId: self.sessionId, sessionId: self.sessionId,
problemId: self.problemId, problemId: problemId ?? self.problemId,
result: result ?? self.result, result: result ?? self.result,
highestHold: highestHold ?? self.highestHold, highestHold: highestHold ?? self.highestHold,
notes: notes ?? self.notes, notes: notes ?? self.notes,

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

@@ -1,4 +1,3 @@
import Combine import Combine
import SwiftUI import SwiftUI
@@ -82,9 +81,9 @@ struct IconAppearanceModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.onChange(of: colorScheme) { _, newColorScheme in .onChange(of: colorScheme) {
iconHelper.updateDarkModeStatus(for: newColorScheme) iconHelper.updateDarkModeStatus(for: colorScheme)
onChange(iconHelper.getRecommendedIconVariant(for: newColorScheme)) onChange(iconHelper.getRecommendedIconVariant(for: colorScheme))
} }
.onAppear { .onAppear {
iconHelper.updateDarkModeStatus(for: colorScheme) iconHelper.updateDarkModeStatus(for: colorScheme)

View File

@@ -3,6 +3,10 @@ import Foundation
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
#if canImport(WidgetKit)
import WidgetKit
#endif
@MainActor @MainActor
class ClimbingDataManager: ObservableObject { class ClimbingDataManager: ObservableObject {
@@ -16,6 +20,7 @@ class ClimbingDataManager: ObservableObject {
@Published var successMessage: String? @Published var successMessage: String?
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
@@ -27,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()
@@ -35,6 +61,9 @@ class ClimbingDataManager: ObservableObject {
Task { Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
await performImageMaintenance() await performImageMaintenance()
// Check if we need to restart Live Activity for active session
await checkAndRestartLiveActivity()
} }
} }
@@ -89,24 +118,60 @@ 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 - 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)
}
} }
} }
private func saveProblems() { private func saveProblems() {
if let data = try? encoder.encode(problems) { if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems) userDefaults.set(data, forKey: Keys.problems)
// Share with widget
sharedUserDefaults?.set(data, forKey: Keys.problems)
} }
} }
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 - 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() { 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 - 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()
} }
} }
@@ -216,6 +281,14 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Session started successfully" successMessage = "Session started successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// MARK: - Start Live Activity for new session
if let gym = gym(withId: gymId) {
Task {
await LiveActivityManager.shared.startLiveActivity(
for: newSession, gymName: gym.name)
}
}
} }
func endSession(_ sessionId: UUID) { func endSession(_ sessionId: UUID) {
@@ -234,6 +307,11 @@ class ClimbingDataManager: ObservableObject {
saveSessions() saveSessions()
successMessage = "Session completed successfully" successMessage = "Session completed successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity()
}
} }
} }
@@ -249,6 +327,9 @@ class ClimbingDataManager: ObservableObject {
saveSessions() saveSessions()
successMessage = "Session updated successfully" successMessage = "Session updated successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when session updates
updateLiveActivityForActiveSession()
} }
} }
@@ -290,6 +371,9 @@ class ClimbingDataManager: ObservableObject {
successMessage = "Attempt logged successfully" successMessage = "Attempt logged successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when new attempt is added
updateLiveActivityForActiveSession()
} }
func updateAttempt(_ attempt: Attempt) { func updateAttempt(_ attempt: Attempt) {
@@ -298,6 +382,9 @@ class ClimbingDataManager: ObservableObject {
saveAttempts() saveAttempts()
successMessage = "Attempt updated successfully" successMessage = "Attempt updated successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when attempt is updated
updateLiveActivityForActiveSession()
} }
} }
@@ -306,6 +393,9 @@ class ClimbingDataManager: ObservableObject {
saveAttempts() saveAttempts()
successMessage = "Attempt deleted successfully" successMessage = "Attempt deleted successfully"
clearMessageAfterDelay() clearMessageAfterDelay()
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
} }
func attempts(forSession sessionId: UUID) -> [Attempt] { func attempts(forSession sessionId: UUID) -> [Attempt] {
@@ -924,6 +1014,124 @@ extension ClimbingDataManager {
""" """
} }
func testLiveActivity() {
print("🧪 Testing Live Activity functionality...")
// Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
print(status)
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
print("❌ No gyms available for testing")
return
}
// Create a test session
let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity")
Task {
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
// Wait a bit then update
try? await Task.sleep(nanoseconds: 2_000_000_000)
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 120, totalAttempts: 5, completedProblems: 1)
// Wait then end
try? await Task.sleep(nanoseconds: 5_000_000_000)
await LiveActivityManager.shared.endLiveActivity()
}
}
private func checkAndRestartLiveActivity() async {
guard let activeSession = activeSession else { return }
if let gym = gym(withId: activeSession.gymId) {
await LiveActivityManager.shared.restartLiveActivityIfNeeded(
activeSession: activeSession,
gymName: gym.name
)
}
}
/// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() {
Task {
await checkAndRestartLiveActivity()
}
}
/// Update Live Activity with current session data
private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession,
activeSession.status == .active,
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
}
let attemptsForSession = attempts(forSession: activeSession.id)
let totalAttempts = attemptsForSession.count
let completedProblemIds = Set(
attemptsForSession.filter { $0.result.isSuccessful }.map { $0.problemId }
)
let completedProblems = completedProblemIds.count
let elapsedInterval: TimeInterval
if let startTime = activeSession.startTime {
elapsedInterval = Date().timeIntervalSince(startTime)
} else {
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,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
}
/// Manually force Live Activity update (useful for debugging)
func forceLiveActivityUpdate() {
updateLiveActivityForActiveSession()
}
/// Update widget timeline when data changes
private func updateWidgetTimeline() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadTimelines(ofKind: "SessionStatusLive")
#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

@@ -0,0 +1,149 @@
import ActivityKit
import Foundation
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private var currentActivity: Activity<SessionActivityAttributes>?
/// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it
guard let activeSession = activeSession,
let gymName = gymName,
activeSession.status == .active
else {
return
}
// Check if we already have a running Live Activity
if currentActivity != nil {
print(" Live Activity already running")
return
}
print("🔄 Restarting Live Activity for existing session")
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("🔴 Starting Live Activity for gym: \(gymName)")
await endLiveActivity()
let attributes = SessionActivityAttributes(
gymName: gymName, startTime: session.startTime ?? session.date)
let initialContentState = SessionActivityAttributes.ContentState(
elapsed: 0,
totalAttempts: 0,
completedProblems: 0
)
do {
let activity = try Activity<SessionActivityAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil
)
self.currentActivity = activity
print("✅ Live Activity started successfully: \(activity.id)")
} catch {
print("❌ Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)")
// Check specific error types
if error.localizedDescription.contains("authorization") {
print("Authorization error - check Live Activity permissions in Settings")
} else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure")
}
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity else {
print("⚠️ No current activity to update")
return
}
print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
)
let updatedContentState = SessionActivityAttributes.ContentState(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("✅ Live Activity updated successfully")
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
// First end the tracked activity if it exists
if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)")
await currentActivity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
print("✅ Tracked Live Activity ended successfully")
}
// Force end ALL active activities of our type to ensure cleanup
print("🔍 Checking for any remaining active activities...")
let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty {
print(" No additional activities found")
} else {
print("🔴 Found \(activities.count) additional active activities, ending them...")
for activity in activities {
print("🔴 Force ending activity: \(activity.id)")
await activity.end(nil, dismissalPolicy: .immediate)
}
print("✅ All Live Activities ended successfully")
}
}
/// Check if Live Activities are available and authorized
func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled
let message = """
Live Activity Status:
• Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
• Current Activity: \(currentActivity?.id.description ?? "None")
"""
print(message)
return message
}
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{
guard currentActivity != nil else { return }
Task {
while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
}
}
}
}

View File

@@ -1,4 +1,4 @@
import PhotosUI
import SwiftUI import SwiftUI
struct AddAttemptView: View { struct AddAttemptView: View {
@@ -20,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)
@@ -127,6 +129,8 @@ struct AddAttemptView: View {
Button("Back") { Button("Back") {
showingCreateProblem = false showingCreateProblem = false
selectedPhotos = []
imageData = []
} }
.foregroundColor(.blue) .foregroundColor(.blue)
} }
@@ -210,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
@@ -311,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)
@@ -348,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 {
@@ -593,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) {
@@ -610,93 +743,13 @@ struct EditAttemptView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { Form {
Section("Problem") { if !showingCreateProblem {
if availableProblems.isEmpty { ProblemSelectionSection()
Text("No problems available") } else {
.foregroundColor(.secondary) CreateProblemSection()
} else {
ForEach(availableProblems, id: \.id) { problem in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
Text(
"\(problem.difficulty.system.displayName): \(problem.difficulty.grade)"
)
.font(.subheadline)
.foregroundColor(.blue)
}
Spacer()
if selectedProblem?.id == problem.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedProblem = problem
}
}
}
} }
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)
@@ -711,29 +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(
result: selectedResult, system: selectedDifficultySystem, grade: newProblemGrade)
highestHold: highestHold.isEmpty ? nil : highestHold,
notes: notes.isEmpty ? nil : notes, // Save images and get paths
duration: duration > 0 ? duration : nil, var imagePaths: [String] = []
restTime: restTime > 0 ? restTime : nil 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() 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 {
@@ -780,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

@@ -7,8 +7,6 @@ struct AnalyticsView: View {
NavigationView { NavigationView {
ScrollView { ScrollView {
LazyVStack(spacing: 20) { LazyVStack(spacing: 20) {
HeaderSection()
OverallStatsSection() OverallStatsSection()
ProgressChartSection() ProgressChartSection()
@@ -26,22 +24,6 @@ struct AnalyticsView: View {
} }
} }
struct HeaderSection: View {
var body: some View {
HStack {
Image("MountainsIcon")
.resizable()
.frame(width: 32, height: 32)
Text("Analytics")
.font(.title)
.fontWeight(.bold)
Spacer()
}
}
}
struct OverallStatsSection: View { struct OverallStatsSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@@ -122,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
@@ -139,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
@@ -182,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)
} }
@@ -219,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)
}
}
} }
} }
@@ -398,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

@@ -1,4 +1,3 @@
import Combine import Combine
import SwiftUI import SwiftUI
@@ -49,7 +48,8 @@ struct SessionDetailView: View {
AttemptsSection( AttemptsSection(
attemptsWithProblems: attemptsWithProblems, attemptsWithProblems: attemptsWithProblems,
attemptToDelete: $attemptToDelete) attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} else { } else {
Text("Session not found") Text("Session not found")
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -345,8 +345,8 @@ struct StatItem: View {
struct AttemptsSection: View { struct AttemptsSection: View {
let attemptsWithProblems: [(Attempt, Problem)] let attemptsWithProblems: [(Attempt, Problem)]
@Binding var attemptToDelete: Attempt? @Binding var attemptToDelete: Attempt?
@Binding var editingAttempt: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var editingAttempt: Attempt?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -376,13 +376,13 @@ struct AttemptsSection: View {
.fill(.regularMaterial) .fill(.regularMaterial)
) )
} else { } else {
LazyVStack(spacing: 12) { List {
ForEach(attemptsWithProblems.indices, id: \.self) { index in ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index] let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem) AttemptCard(attempt: attempt, problem: problem)
.background(.regularMaterial) .listRowBackground(Color.clear)
.cornerRadius(12) .listRowSeparator(.hidden)
.shadow(radius: 2) .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { Button(role: .destructive) {
// Add haptic feedback for delete action // Add haptic feedback for delete action
@@ -409,11 +409,12 @@ struct AttemptsSection: View {
} }
} }
} }
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: CGFloat(attemptsWithProblems.count) * 120)
} }
} }
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
} }
} }
@@ -460,6 +461,9 @@ struct AttemptCard: View {
} }
} }
.padding() .padding()
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
} }
} }

View File

@@ -0,0 +1,280 @@
//
// LiveActivityDebugView.swift
// OpenClimb
//
// Created by Assistant on 2025-09-15.
//
import SwiftUI
struct LiveActivityDebugView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var debugOutput: String = ""
@State private var isTestRunning = false
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 20) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("Live Activity Debug")
.font(.title)
.fontWeight(.bold)
Text("Test and debug Live Activities for climbing sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Status Section
GroupBox("Current Status") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(dataManager.activeSession != nil ? .green : .red)
Text(
"Active Session: \(dataManager.activeSession != nil ? "Yes" : "No")"
)
}
HStack {
Image(systemName: "building.2")
Text("Total Gyms: \(dataManager.gyms.count)")
}
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
HStack {
Image(systemName: "location")
Text("Current Gym: \(gym.name)")
}
}
}
}
// Test Buttons
GroupBox("Live Activity Tests") {
VStack(spacing: 16) {
Button(action: checkStatus) {
HStack {
Image(systemName: "checkmark.circle")
Text("Check Live Activity Status")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
Button(action: testLiveActivity) {
HStack {
Image(systemName: isTestRunning ? "hourglass" : "play.circle")
Text(
isTestRunning
? "Running Test..." : "Run Full Live Activity Test")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isTestRunning || dataManager.gyms.isEmpty)
Button(action: forceLiveActivityUpdate) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Force Live Activity Update")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("⚠️ Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
if dataManager.activeSession != nil {
Button(action: endCurrentSession) {
HStack {
Image(systemName: "stop.circle")
Text("End Current Session")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
}
}
}
// Debug Output
GroupBox("Debug Output") {
ScrollView {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 4) {
if debugOutput.isEmpty {
Text("No debug output yet. Run a test to see details.")
.foregroundColor(.secondary)
.italic()
} else {
Text(debugOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.id("bottom")
.onChange(of: debugOutput) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.frame(maxHeight: 200)
.background(Color(UIColor.systemGray6))
.cornerRadius(8)
}
// Clear button
HStack {
Spacer()
Button("Clear Output") {
debugOutput = ""
}
.buttonStyle(.bordered)
}
Spacer()
}
.padding()
}
.navigationTitle("Live Activity Debug")
.navigationBarTitleDisplayMode(.inline)
}
private func appendDebugOutput(_ message: String) {
let timestamp = DateFormatter.timeFormatter.string(from: Date())
let newLine = "[\(timestamp)] \(message)"
DispatchQueue.main.async {
if debugOutput.isEmpty {
debugOutput = newLine
} else {
debugOutput += "\n" + newLine
}
}
}
private func checkStatus() {
appendDebugOutput("🔍 Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("✅ iOS version supports Live Activities")
} else {
appendDebugOutput("❌ iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("✅ Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput("❌ No gyms available for testing")
return
}
isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...")
Task {
defer {
DispatchQueue.main.async {
isTestRunning = false
}
}
// Test with first gym
let testGym = dataManager.gyms[0]
appendDebugOutput("Using gym: \(testGym.name)")
// Create test session
let testSession = ClimbSession(
gymId: testGym.id, notes: "Test session for Live Activity")
appendDebugOutput("Created test session")
// Start Live Activity
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
appendDebugOutput("Live Activity start request sent")
// Wait and update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180,
totalAttempts: 8,
completedProblems: 2
)
// Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360,
totalAttempts: 15,
completedProblems: 4
)
// End after delay
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("🏁 Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput("❌ No active session to end")
return
}
appendDebugOutput("🛑 Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("✅ Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("🔄 Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("✅ Live Activity update sent")
}
}
extension DateFormatter {
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}
#Preview {
LiveActivityDebugView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -1,4 +1,3 @@
import Combine import Combine
import SwiftUI import SwiftUI
@@ -8,19 +7,7 @@ struct SessionsView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 0) { Group {
// Active session banner
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
VStack(spacing: 8) {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal)
}
.padding(.top, 8)
}
// Sessions list
if dataManager.sessions.isEmpty && dataManager.activeSession == nil { if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView() EmptySessionsView()
} else { } else {
@@ -28,6 +15,7 @@ struct SessionsView: View {
} }
} }
.navigationTitle("Sessions") .navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
@@ -47,75 +35,7 @@ struct SessionsView: View {
AddEditSessionView() AddEditSessionView()
} }
} }
} .navigationViewStyle(.stack)
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.green)
.font(.caption)
Text("Active Session")
.font(.headline)
.fontWeight(.bold)
}
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Button("End") {
dataManager.endSession(session.id)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
.onReceive(timer) { _ in
currentTime = Date()
}
}
private func formatDuration(from start: Date, to end: Date) -> String {
let interval = end.timeIntervalSince(start)
let hours = Int(interval) / 3600
let minutes = Int(interval) % 3600 / 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%dh %dm %ds", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, seconds)
} else {
return String(format: "%ds", seconds)
}
} }
} }
@@ -130,18 +50,45 @@ struct SessionsList: View {
} }
var body: some View { var body: some View {
List(completedSessions) { session in List {
NavigationLink(destination: SessionDetailView(sessionId: session.id)) { // Active session banner section
SessionRow(session: session) if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
Section {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) { // Completed sessions section
sessionToDelete = session if !completedSessions.isEmpty {
} label: { Section {
Label("Delete", systemImage: "trash") ForEach(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
if dataManager.activeSession != nil {
Text("Previous Sessions")
.font(.headline)
.fontWeight(.semibold)
}
} }
} }
} }
.listStyle(.insetGrouped)
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
sessionToDelete = nil sessionToDelete = nil
@@ -160,6 +107,91 @@ struct SessionsList: View {
} }
} }
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
@State private var navigateToDetail = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.green)
.font(.caption)
Text("Active Session")
.font(.headline)
.fontWeight(.bold)
}
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime))
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
navigateToDetail = true
}
Button(action: {
dataManager.endSession(session.id)
}) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.red)
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.onReceive(timer) { _ in
currentTime = Date()
}
.background(
NavigationLink(
destination: SessionDetailView(sessionId: session.id),
isActive: $navigateToDetail
) {
EmptyView()
}
.hidden()
)
}
private func formatDuration(from start: Date, to end: Date) -> String {
let interval = end.timeIntervalSince(start)
let hours = Int(interval) / 3600
let minutes = Int(interval) % 3600 / 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%dh %dm %ds", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, seconds)
} else {
return String(format: "%ds", seconds)
}
}
}
struct SessionRow: View { struct SessionRow: View {
let session: ClimbSession let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -17,8 +16,6 @@ struct SettingsView: View {
activeSheet: $activeSheet activeSheet: $activeSheet
) )
ImageStorageSection()
AppInfoSection() AppInfoSection()
} }
.navigationTitle("Settings") .navigationTitle("Settings")
@@ -126,96 +123,6 @@ struct DataManagementSection: View {
} }
} }
struct ImageStorageSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingStorageInfo = false
@State private var storageInfo = ""
@State private var showingRecoveryAlert = false
@State private var showingEmergencyAlert = false
var body: some View {
Section("Image Storage") {
// Storage Status
Button(action: {
storageInfo = dataManager.getImageRecoveryStatus()
showingStorageInfo = true
}) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Check Storage Health")
Spacer()
}
}
.foregroundColor(.primary)
// Manual Maintenance
Button(action: {
dataManager.manualImageMaintenance()
}) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(.orange)
Text("Run Maintenance")
Spacer()
}
}
.foregroundColor(.primary)
// Force Recovery
Button(action: {
showingRecoveryAlert = true
}) {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.orange)
Text("Force Image Recovery")
Spacer()
}
}
.foregroundColor(.primary)
// Emergency Restore
Button(action: {
showingEmergencyAlert = true
}) {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text("Emergency Restore")
Spacer()
}
}
.foregroundColor(.red)
}
.alert("Storage Information", isPresented: $showingStorageInfo) {
Button("OK") {}
} message: {
Text(storageInfo)
}
.alert("Force Image Recovery", isPresented: $showingRecoveryAlert) {
Button("Cancel", role: .cancel) {}
Button("Force Recovery", role: .destructive) {
dataManager.forceImageRecovery()
}
} message: {
Text(
"This will attempt to recover missing images from backups and legacy locations. Use this if images are missing after app updates or debug sessions."
)
}
.alert("Emergency Restore", isPresented: $showingEmergencyAlert) {
Button("Cancel", role: .cancel) {}
Button("Emergency Restore", role: .destructive) {
dataManager.emergencyImageRestore()
}
} message: {
Text(
"This will restore all images from the backup directory, potentially overwriting current images. Only use this if normal recovery fails."
)
}
}
}
struct AppInfoSection: View { struct AppInfoSection: View {
private var appVersion: String { private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"

View File

@@ -0,0 +1,18 @@
//
// AppIntent.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,339 @@
//
// SessionStatusLive.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import SwiftUI
import WidgetKit
struct ClimbingStatsProvider: TimelineProvider {
typealias Entry = ClimbingStatsEntry
func placeholder(in context: Context) -> ClimbingStatsEntry {
ClimbingStatsEntry(
date: Date(),
weeklyAttempts: 42,
weeklySessions: 5,
currentStreak: 3,
favoriteGym: "Summit Climbing"
)
}
func getSnapshot(in context: Context, completion: @escaping (ClimbingStatsEntry) -> Void) {
let entry = ClimbingStatsEntry(
date: Date(),
weeklyAttempts: 42,
weeklySessions: 5,
currentStreak: 3,
favoriteGym: "Summit Climbing"
)
completion(entry)
}
func getTimeline(
in context: Context, completion: @escaping (Timeline<ClimbingStatsEntry>) -> Void
) {
let currentDate = Date()
let stats = loadClimbingStats()
let entry = ClimbingStatsEntry(
date: currentDate,
weeklyAttempts: stats.weeklyAttempts,
weeklySessions: stats.weeklySessions,
currentStreak: stats.currentStreak,
favoriteGym: stats.favoriteGym
)
// Update every hour
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func loadClimbingStats() -> ClimbingStats {
let userDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
// Load attempts from UserDefaults
guard let attemptsData = userDefaults?.data(forKey: "openclimb_attempts"),
let attempts = try? JSONDecoder().decode([WidgetAttempt].self, from: attemptsData)
else {
return ClimbingStats(
weeklyAttempts: 0, weeklySessions: 0, currentStreak: 0, favoriteGym: "No Data")
}
// Load sessions for streak calculation
let sessionsData = (userDefaults?.data(forKey: "openclimb_sessions"))!
let sessions = (try? JSONDecoder().decode([WidgetSession].self, from: sessionsData)) ?? []
// Load gyms for favorite gym name
let gymsData = (userDefaults?.data(forKey: "openclimb_gyms"))!
let gyms = (try? JSONDecoder().decode([WidgetGym].self, from: gymsData)) ?? []
let calendar = Calendar.current
let now = Date()
let weekAgo = calendar.date(byAdding: .day, value: -7, to: now)!
_ = calendar.startOfDay(for: now)
// Calculate weekly attempts
let weeklyAttempts = attempts.filter { attempt in
attempt.timestamp >= weekAgo
}.count
// Calculate weekly sessions
let weeklySessions = sessions.filter { session in
session.date >= weekAgo && session.status == "COMPLETED"
}.count
// Calculate current streak (consecutive days with sessions)
let currentStreak = calculateStreak(sessions: sessions)
// Find favorite gym
let favoriteGym = findFavoriteGym(sessions: sessions, gyms: gyms)
return ClimbingStats(
weeklyAttempts: weeklyAttempts,
weeklySessions: weeklySessions,
currentStreak: currentStreak,
favoriteGym: favoriteGym
)
}
private func calculateStreak(sessions: [WidgetSession]) -> Int {
let calendar = Calendar.current
let completedSessions = sessions.filter { $0.status == "COMPLETED" }
.sorted { $0.date > $1.date }
guard !completedSessions.isEmpty else { return 0 }
var streak = 0
var currentDate = calendar.startOfDay(for: Date())
for session in completedSessions {
let sessionDate = calendar.startOfDay(for: session.date)
if sessionDate == currentDate {
streak += 1
currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)!
} else if sessionDate == calendar.date(byAdding: .day, value: -1, to: currentDate) {
streak += 1
currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)!
} else {
break
}
}
return streak
}
private func findFavoriteGym(sessions: [WidgetSession], gyms: [WidgetGym]) -> String {
let gymCounts = Dictionary(grouping: sessions, by: { $0.gymId })
.mapValues { $0.count }
guard let mostUsedGymId = gymCounts.max(by: { $0.value < $1.value })?.key,
let gym = gyms.first(where: { $0.id == mostUsedGymId })
else {
return "No Data"
}
return gym.name
}
}
struct ClimbingStatsEntry: TimelineEntry {
let date: Date
let weeklyAttempts: Int
let weeklySessions: Int
let currentStreak: Int
let favoriteGym: String
}
struct ClimbingStats {
let weeklyAttempts: Int
let weeklySessions: Int
let currentStreak: Int
let favoriteGym: String
}
struct SessionStatusLiveEntryView: View {
var entry: ClimbingStatsEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
default:
SmallWidgetView(entry: entry)
}
}
}
struct SmallWidgetView: View {
let entry: ClimbingStatsEntry
var body: some View {
VStack(spacing: 12) {
// Header
HStack {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "figure.climbing")
.font(.title2)
.foregroundColor(.accentColor)
}
Spacer()
Text("Weekly")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
// Main stats - weekly attempts and sessions
VStack(spacing: 16) {
HStack(spacing: 8) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.title2)
Text("\(entry.weeklyAttempts)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
}
HStack(spacing: 8) {
Image(systemName: "play.fill")
.foregroundColor(.blue)
.font(.title2)
Text("\(entry.weeklySessions)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
}
}
Spacer()
}
.padding()
}
}
struct MediumWidgetView: View {
let entry: ClimbingStatsEntry
var body: some View {
VStack(spacing: 16) {
// Header
HStack {
HStack(spacing: 6) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "figure.climbing")
.font(.title2)
.foregroundColor(.accentColor)
}
Text("Weekly")
.font(.headline)
.fontWeight(.semibold)
}
Spacer()
}
// Main stats row - weekly attempts and sessions
HStack(spacing: 40) {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.title2)
Text("\(entry.weeklyAttempts)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
}
}
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "play.fill")
.foregroundColor(.blue)
.font(.title2)
Text("\(entry.weeklySessions)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
}
}
}
Spacer()
}
.padding()
}
}
struct SessionStatusLive: Widget {
let kind: String = "SessionStatusLive"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ClimbingStatsProvider()) { entry in
SessionStatusLiveEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Climbing Stats")
.description("Track your climbing attempts and streaks")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// Simplified data models for widget use
struct WidgetAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let timestamp: Date
let result: String
}
struct WidgetSession: Codable {
let id: String
let gymId: String
let date: Date
let status: String
}
struct WidgetGym: Codable {
let id: String
let name: String
}
#Preview(as: .systemSmall) {
SessionStatusLive()
} timeline: {
ClimbingStatsEntry(
date: .now,
weeklyAttempts: 42,
weeklySessions: 5,
currentStreak: 3,
favoriteGym: "Summit Climbing"
)
ClimbingStatsEntry(
date: .now,
weeklyAttempts: 58,
weeklySessions: 8,
currentStreak: 5,
favoriteGym: "Boulder Zone"
)
}

View File

@@ -0,0 +1,18 @@
//
// SessionStatusLiveBundle.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import SwiftUI
@main
struct SessionStatusLiveBundle: WidgetBundle {
var body: some Widget {
SessionStatusLive()
SessionStatusLiveControl()
SessionStatusLiveLiveActivity()
}
}

View File

@@ -0,0 +1,77 @@
//
// SessionStatusLiveControl.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import AppIntents
import SwiftUI
import WidgetKit
struct SessionStatusLiveControl: ControlWidget {
static let kind: String = "com.atridad.OpenClimb.SessionStatusLive"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension SessionStatusLiveControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -0,0 +1,223 @@
//
// SessionStatusLiveLiveActivity.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import ActivityKit
import SwiftUI
import WidgetKit
struct SessionActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
struct SessionStatusLiveLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: SessionActivityAttributes.self) { context in
LiveActivityView(context: context)
.activityBackgroundTint(Color.blue.opacity(0.2))
.activitySystemActionForegroundColor(Color.primary)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "figure.climbing")
.font(.title3)
.foregroundColor(.accentColor)
}
Text(context.attributes.gymName)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 4) {
LiveTimerView(start: context.attributes.startTime)
.font(.title2)
.fontWeight(.bold)
.monospacedDigit()
HStack(spacing: 4) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.caption)
Text("\(context.state.totalAttempts)")
.font(.caption)
.fontWeight(.semibold)
Text("attempts")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text("\(context.state.completedProblems) completed")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text("Tap to open")
.font(.caption2)
.foregroundColor(.secondary)
}
}
} compactLeading: {
Image(systemName: "figure.climbing")
.font(.footnote)
.foregroundColor(.accentColor)
} compactTrailing: {
LiveTimerView(start: context.attributes.startTime, compact: true)
} minimal: {
Image(systemName: "figure.climbing")
.font(.system(size: 8))
.foregroundColor(.accentColor)
}
}
}
}
struct LiveActivityView: View {
let context: ActivityViewContext<SessionActivityAttributes>
var body: some View {
HStack(spacing: 16) {
LiveTimerView(start: context.attributes.startTime)
.font(.largeTitle)
.fontWeight(.bold)
.monospacedDigit()
.frame(minWidth: 80)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 7))
} else {
Image(systemName: "figure.climbing")
.font(.title2)
.foregroundColor(.accentColor)
}
VStack(alignment: .leading, spacing: 0) {
Text(context.attributes.gymName)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(1)
Text("Climbing Session")
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.title3)
Text("\(context.state.totalAttempts)")
.font(.title2)
.fontWeight(.bold)
}
Text("Total Attempts")
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title3)
Text("\(context.state.completedProblems)")
.font(.title2)
.fontWeight(.bold)
}
Text("Completed")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct LiveTimerView: View {
let start: Date
let compact: Bool
let minimal: Bool
init(start: Date, compact: Bool = false, minimal: Bool = false) {
self.start = start
self.compact = compact
self.minimal = minimal
}
var body: some View {
if minimal {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.system(size: 8, weight: .medium, design: .monospaced))
} else if compact {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.caption.monospacedDigit())
.frame(maxWidth: 40)
.minimumScaleFactor(0.7)
} else {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.monospacedDigit()
}
}
}
// Alias for compatibility
typealias TimerView = LiveTimerView
extension SessionActivityAttributes {
fileprivate static var preview: SessionActivityAttributes {
SessionActivityAttributes(
gymName: "Summit Climbing Gym", startTime: Date().addingTimeInterval(-1234))
}
}
extension SessionActivityAttributes.ContentState {
fileprivate static var active: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 1234, totalAttempts: 8, completedProblems: 2)
}
fileprivate static var busy: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 3600, totalAttempts: 25, completedProblems: 7)
}
}
#Preview("Notification", as: .content, using: SessionActivityAttributes.preview) {
SessionStatusLiveLiveActivity()
} contentStates: {
SessionActivityAttributes.ContentState.active
SessionActivityAttributes.ContentState.busy
}

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>