Compare commits
19 Commits
9df0b29ada
...
ANDROID_1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
416b68e96a
|
|||
|
f68963afbc
|
|||
|
f1bc61d202
|
|||
|
57b16c89ad
|
|||
|
44b9b7bb9e
|
|||
|
7839d52001
|
|||
|
fff8123978
|
|||
|
6172074509
|
|||
|
0235b5d506
|
|||
|
7c18b56674
|
|||
|
cccdc2dd66
|
|||
|
62703cf2eb
|
|||
|
2c0ae23417
|
|||
|
87dcd08189
|
|||
|
f3dabbd3aa
|
|||
|
e4c6440758
|
|||
|
b478f05260
|
|||
|
afd954785a
|
|||
| d95c45abbb |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
143
ios/ClimbingActivityWidget/ClimbingActivityWidget.swift
Normal file
143
ios/ClimbingActivityWidget/ClimbingActivityWidget.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/ClimbingActivityWidget/Info.plist
Normal file
31
ios/ClimbingActivityWidget/Info.plist
Normal 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>
|
||||||
50
ios/OpenClimb.xcodeproj/LiveActivityManager.swift
Normal file
50
ios/OpenClimb.xcodeproj/LiveActivityManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
19
ios/OpenClimb/Models/ActivityAttributes.swift
Normal file
19
ios/OpenClimb/Models/ActivityAttributes.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
10
ios/OpenClimb/OpenClimb.entitlements
Normal file
10
ios/OpenClimb/OpenClimb.entitlements
Normal 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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
149
ios/OpenClimb/ViewModels/LiveActivityManager.swift
Normal file
149
ios/OpenClimb/ViewModels/LiveActivityManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
280
ios/OpenClimb/Views/LiveActivityDebugView.swift
Normal file
280
ios/OpenClimb/Views/LiveActivityDebugView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
18
ios/SessionStatusLive/AppIntent.swift
Normal file
18
ios/SessionStatusLive/AppIntent.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ios/SessionStatusLive/Info.plist
Normal file
11
ios/SessionStatusLive/Info.plist
Normal 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>
|
||||||
339
ios/SessionStatusLive/SessionStatusLive.swift
Normal file
339
ios/SessionStatusLive/SessionStatusLive.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal file
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal file
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal file
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal 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
|
||||||
|
}
|
||||||
10
ios/SessionStatusLiveExtension.entitlements
Normal file
10
ios/SessionStatusLiveExtension.entitlements
Normal 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>
|
||||||
Reference in New Issue
Block a user