Compare commits

..

5 Commits

32 changed files with 3498 additions and 2629 deletions

View File

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

View File

@@ -7,62 +7,58 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ProblemDao { interface ProblemDao {
@Query("SELECT * FROM problems ORDER BY updatedAt DESC") @Query("SELECT * FROM problems ORDER BY updatedAt DESC")
fun getAllProblems(): Flow<List<Problem>> fun getAllProblems(): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE id = :id") @Query("SELECT * FROM problems WHERE id = :id") suspend fun getProblemById(id: String): Problem?
suspend fun getProblemById(id: String): Problem?
@Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE gymId = :gymId ORDER BY updatedAt DESC")
fun getProblemsByGym(gymId: String): Flow<List<Problem>> fun getProblemsByGym(gymId: String): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE climbType = :climbType ORDER BY updatedAt DESC")
fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>> fun getProblemsByClimbType(climbType: ClimbType): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC") @Query(
"SELECT * FROM problems WHERE gymId = :gymId AND climbType = :climbType ORDER BY updatedAt DESC"
)
fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>> fun getProblemsByGymAndType(gymId: String, climbType: ClimbType): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE isActive = 1 ORDER BY updatedAt DESC")
fun getActiveProblems(): Flow<List<Problem>> fun getActiveProblems(): Flow<List<Problem>>
@Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC") @Query("SELECT * FROM problems WHERE gymId = :gymId AND isActive = 1 ORDER BY updatedAt DESC")
fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>> fun getActiveProblemsByGym(gymId: String): Flow<List<Problem>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProblem(problem: Problem)
suspend fun insertProblem(problem: Problem)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProblems(problems: List<Problem>) suspend fun insertProblems(problems: List<Problem>)
@Update @Update suspend fun updateProblem(problem: Problem)
suspend fun updateProblem(problem: Problem)
@Delete suspend fun deleteProblem(problem: Problem)
@Delete
suspend fun deleteProblem(problem: Problem) @Query("DELETE FROM problems WHERE id = :id") suspend fun deleteProblemById(id: String)
@Query("DELETE FROM problems WHERE id = :id")
suspend fun deleteProblemById(id: String)
@Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId") @Query("SELECT COUNT(*) FROM problems WHERE gymId = :gymId")
suspend fun getProblemsCountByGym(gymId: String): Int suspend fun getProblemsCountByGym(gymId: String): Int
@Query("SELECT COUNT(*) FROM problems WHERE isActive = 1") @Query("SELECT COUNT(*) FROM problems WHERE isActive = 1")
suspend fun getActiveProblemsCount(): Int suspend fun getActiveProblemsCount(): Int
@Query(""" @Query(
SELECT * FROM problems """
WHERE (name LIKE '%' || :searchQuery || '%' SELECT * FROM problems
WHERE (name LIKE '%' || :searchQuery || '%'
OR description LIKE '%' || :searchQuery || '%' OR description LIKE '%' || :searchQuery || '%'
OR location LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%')
OR setter LIKE '%' || :searchQuery || '%')
ORDER BY updatedAt DESC ORDER BY updatedAt DESC
""") """
)
fun searchProblems(searchQuery: String): Flow<List<Problem>> fun searchProblems(searchQuery: String): Flow<List<Problem>>
@Query("SELECT COUNT(*) FROM problems") @Query("SELECT COUNT(*) FROM problems") suspend fun getProblemsCount(): Int
suspend fun getProblemsCount(): Int
@Query("DELETE FROM problems") suspend fun deleteAllProblems()
@Query("DELETE FROM problems")
suspend fun deleteAllProblems()
} }

View File

@@ -4,71 +4,67 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlinx.serialization.Serializable
@Entity( @Entity(
tableName = "problems", tableName = "problems",
foreignKeys = [ foreignKeys =
ForeignKey( [
entity = Gym::class, ForeignKey(
parentColumns = ["id"], entity = Gym::class,
childColumns = ["gymId"], parentColumns = ["id"],
onDelete = ForeignKey.CASCADE childColumns = ["gymId"],
) onDelete = ForeignKey.CASCADE
], )],
indices = [Index(value = ["gymId"])] indices = [Index(value = ["gymId"])]
) )
@Serializable @Serializable
data class Problem( data class Problem(
@PrimaryKey @PrimaryKey val id: String,
val id: String, val gymId: String,
val gymId: String, val name: String? = null,
val name: String? = null, val description: String? = null,
val description: String? = null, val climbType: ClimbType,
val climbType: ClimbType, val difficulty: DifficultyGrade,
val difficulty: DifficultyGrade, val tags: List<String> = emptyList(),
val setter: String? = null, val location: String? = null,
val tags: List<String> = emptyList(), val imagePaths: List<String> = emptyList(),
val location: String? = null, val isActive: Boolean = true,
val imagePaths: List<String> = emptyList(), val dateSet: String? = null,
val isActive: Boolean = true, val notes: String? = null,
val dateSet: String? = null, val createdAt: String,
val notes: String? = null, val updatedAt: String
val createdAt: String,
val updatedAt: String
) { ) {
companion object { companion object {
fun create( fun create(
gymId: String, gymId: String,
name: String? = null, name: String? = null,
description: String? = null, description: String? = null,
climbType: ClimbType, climbType: ClimbType,
difficulty: DifficultyGrade, difficulty: DifficultyGrade,
setter: String? = null, tags: List<String> = emptyList(),
tags: List<String> = emptyList(), location: String? = null,
location: String? = null, imagePaths: List<String> = emptyList(),
imagePaths: List<String> = emptyList(), dateSet: String? = null,
dateSet: String? = null, notes: String? = null
notes: String? = null
): Problem { ): Problem {
val now = LocalDateTime.now().toString() val now = LocalDateTime.now().toString()
return Problem( return Problem(
id = java.util.UUID.randomUUID().toString(), id = java.util.UUID.randomUUID().toString(),
gymId = gymId, gymId = gymId,
name = name, name = name,
description = description, description = description,
climbType = climbType, climbType = climbType,
difficulty = difficulty, difficulty = difficulty,
setter = setter, tags = tags,
tags = tags, location = location,
location = location, imagePaths = imagePaths,
imagePaths = imagePaths, isActive = true,
isActive = true, dateSet = dateSet,
dateSet = dateSet, notes = notes,
notes = notes, createdAt = now,
createdAt = now, updatedAt = now
updatedAt = now
) )
} }
} }

View File

@@ -82,7 +82,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
val exportData = val exportData =
ClimbDataExport( ClimbDataExport(
exportedAt = LocalDateTime.now().toString(), exportedAt = LocalDateTime.now().toString(),
version = "1.0", version = "2.0",
gyms = allGyms, gyms = allGyms,
problems = allProblems, problems = allProblems,
sessions = allSessions, sessions = allSessions,
@@ -141,7 +141,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
val exportData = val exportData =
ClimbDataExport( ClimbDataExport(
exportedAt = LocalDateTime.now().toString(), exportedAt = LocalDateTime.now().toString(),
version = "1.0", version = "2.0",
gyms = allGyms, gyms = allGyms,
problems = allProblems, problems = allProblems,
sessions = allSessions, sessions = allSessions,
@@ -343,7 +343,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
data class ClimbDataExport( data class ClimbDataExport(
val exportedAt: String, val exportedAt: String,
val version: String = "1.0", val version: String = "2.0",
val gyms: List<Gym>, val gyms: List<Gym>,
val problems: List<Problem>, val problems: List<Problem>,
val sessions: List<ClimbSession>, val sessions: List<ClimbSession>,

View File

@@ -0,0 +1,208 @@
package com.atridad.openclimb.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Data point for the bar chart */
data class BarChartDataPoint(val label: String, val value: Int, val gradeNumeric: Int)
/** Configuration for bar chart styling */
data class BarChartStyle(
val barColor: Color,
val gridColor: Color,
val textColor: Color,
val backgroundColor: Color
)
/** Custom Bar Chart for displaying grade distribution */
@Composable
fun BarChart(
data: List<BarChartDataPoint>,
modifier: Modifier = Modifier,
style: BarChartStyle =
BarChartStyle(
barColor = MaterialTheme.colorScheme.primary,
gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
backgroundColor = MaterialTheme.colorScheme.surface
),
showGrid: Boolean = true
) {
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
if (data.isEmpty()) return@Canvas
val padding = with(density) { 32.dp.toPx() }
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Sort data by grade numeric value for proper ordering
val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value for scaling
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions
val barCount = sortedData.size
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount
// Draw background
drawRect(
color = style.backgroundColor,
topLeft = Offset(padding, padding),
size = androidx.compose.ui.geometry.Size(chartWidth, chartHeight)
)
// Draw grid
if (showGrid) {
drawGrid(
padding = padding,
chartWidth = chartWidth,
chartHeight = chartHeight,
gridColor = style.gridColor,
maxValue = maxValue,
textMeasurer = textMeasurer,
textColor = style.textColor
)
}
// Draw bars and labels
sortedData.forEachIndexed { index, dataPoint ->
val barHeight =
if (maxValue > 0) {
(dataPoint.value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
} else 0f
val barX =
padding +
barSpacing +
index * (barWidth + barSpacing / (barCount - 1).coerceAtLeast(1))
val barY = padding + chartHeight - barHeight
// Draw bar
drawRect(
color = style.barColor,
topLeft = Offset(barX, barY),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
)
// Draw value on top of bar (if there's space)
if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough
val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar
} else {
barY - 4.dp.toPx() // Above bar
}
val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar
} else {
style.textColor // Regular color above bar
}
drawText(
textMeasurer = textMeasurer,
text = valueText,
style = textStyle.copy(color = textColor),
topLeft = Offset(barX + barWidth / 2f - textSize.size.width / 2f, textY)
)
}
// Draw grade label below bar
val gradeText = dataPoint.label
val labelTextStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val labelTextSize = textMeasurer.measure(gradeText, labelTextStyle)
drawText(
textMeasurer = textMeasurer,
text = gradeText,
style = labelTextStyle,
topLeft =
Offset(
barX + barWidth / 2f - labelTextSize.size.width / 2f,
padding + chartHeight + 8.dp.toPx()
)
)
}
}
}
}
private fun DrawScope.drawGrid(
padding: Float,
chartWidth: Float,
chartHeight: Float,
gridColor: Color,
maxValue: Int,
textMeasurer: TextMeasurer,
textColor: Color
) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis)
val gridLines =
when {
maxValue <= 5 -> (0..maxValue).toList()
maxValue <= 10 -> (0..maxValue step 2).toList()
maxValue <= 20 -> (0..maxValue step 5).toList()
else -> {
val step = (maxValue / 5).coerceAtLeast(1)
(0..maxValue step step).toList()
}
}
gridLines.forEach { value ->
val y = padding + chartHeight - (value.toFloat() / maxValue.toFloat()) * chartHeight * 0.8f
// Draw grid line
drawLine(
color = gridColor,
start = Offset(padding, y),
end = Offset(padding + chartWidth, y),
strokeWidth = 1.dp.toPx()
)
// Draw Y-axis label
if (value >= 0) {
val text = value.toString()
val textSize = textMeasurer.measure(text, textStyle)
drawText(
textMeasurer = textMeasurer,
text = text,
style = textStyle,
topLeft =
Offset(
padding - textSize.size.width - 8.dp.toPx(),
y - textSize.size.height / 2f
)
)
}
}
}

View File

@@ -10,78 +10,76 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R import com.atridad.openclimb.R
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.DifficultySystem import com.atridad.openclimb.data.model.DifficultySystem
import com.atridad.openclimb.ui.components.ChartDataPoint import com.atridad.openclimb.ui.components.BarChart
import com.atridad.openclimb.ui.components.LineChart import com.atridad.openclimb.ui.components.BarChartDataPoint
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable @Composable
fun AnalyticsScreen( fun AnalyticsScreen(viewModel: ClimbViewModel) {
viewModel: ClimbViewModel
) {
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(16.dp),
.fillMaxSize() verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_mountains), painter = painterResource(id = R.drawable.ic_mountains),
contentDescription = "OpenClimb Logo", contentDescription = "OpenClimb Logo",
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = "Analytics", text = "Analytics",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
} }
// Overall Stats // Overall Stats
item { item {
OverallStatsCard( OverallStatsCard(
totalSessions = sessions.size, totalSessions = sessions.size,
totalProblems = problems.size, totalProblems = problems.size,
totalAttempts = attempts.size, totalAttempts = attempts.size,
totalGyms = gyms.size totalGyms = gyms.size
) )
} }
// Progress Chart // Grade Distribution Chart
item { item {
val progressData = calculateProgressOverTime(sessions, problems, attempts) val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
ProgressChartCard(progressData = progressData, problems = problems) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
} }
// Favorite Gym // Favorite Gym
item { item {
val favoriteGym = sessions val favoriteGym =
.groupBy { it.gymId } sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
.maxByOrNull { it.value.size } (gymId, sessions) ->
?.let { (gymId, sessions) -> gyms.find { it.id == gymId }?.name to sessions.size
gyms.find { it.id == gymId }?.name to sessions.size }
}
FavoriteGymCard( FavoriteGymCard(
gymName = favoriteGym?.first ?: "No sessions yet", gymName = favoriteGym?.first ?: "No sessions yet",
sessionCount = favoriteGym?.second ?: 0 sessionCount = favoriteGym?.second ?: 0
) )
} }
// Recent Activity // Recent Activity
item { item {
val recentSessions = sessions.take(5) val recentSessions = sessions.take(5)
@@ -91,31 +89,20 @@ fun AnalyticsScreen(
} }
@Composable @Composable
fun OverallStatsCard( fun OverallStatsCard(totalSessions: Int, totalProblems: Int, totalAttempts: Int, totalGyms: Int) {
totalSessions: Int, Card(modifier = Modifier.fillMaxWidth()) {
totalProblems: Int, Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
totalAttempts: Int,
totalGyms: Int
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Overall Stats", text = "Overall Stats",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
StatItem(label = "Sessions", value = totalSessions.toString()) StatItem(label = "Sessions", value = totalSessions.toString())
StatItem(label = "Problems", value = totalProblems.toString()) StatItem(label = "Problems", value = totalProblems.toString())
@@ -128,178 +115,241 @@ fun OverallStatsCard(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProgressChartCard( fun GradeDistributionChartCard(gradeDistributionData: List<GradeDistributionDataPoint>) {
progressData: List<ProgressDataPoint>, // Find all grading systems that have been used in the data
problems: List<com.atridad.openclimb.data.model.Problem>, val usedSystems =
) { remember(gradeDistributionData) {
// Find all grading systems that have been used in the progress data gradeDistributionData.map { it.difficultySystem }.distinct()
val usedSystems = remember(progressData) { }
progressData.map { it.difficultySystem }.distinct()
} var selectedSystem by
remember(usedSystems) {
var selectedSystem by remember(usedSystems) { mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE)
mutableStateOf(usedSystems.firstOrNull() ?: DifficultySystem.V_SCALE) }
}
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showAllTime by remember { mutableStateOf(true) }
Card(
modifier = Modifier.fillMaxWidth() Card(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column( Text(
modifier = Modifier text = "Grade Distribution",
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Progress Over Time",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold
modifier = Modifier.weight(1f) )
)
Spacer(modifier = Modifier.height(12.dp))
// Toggles section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Time period toggle
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// All Time button
FilterChip(
onClick = { showAllTime = true },
label = {
Text("All Time", style = MaterialTheme.typography.bodySmall)
},
selected = showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
// 7 Days button
FilterChip(
onClick = { showAllTime = false },
label = { Text("7 Days", style = MaterialTheme.typography.bodySmall) },
selected = !showAllTime,
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor =
MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
// Scale selector dropdown // Scale selector dropdown
if (usedSystems.size > 1) { if (usedSystems.size > 1) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = !expanded } onExpandedChange = { expanded = !expanded }
) { ) {
OutlinedTextField( OutlinedTextField(
value = when (selectedSystem) { value =
DifficultySystem.V_SCALE -> "V-Scale" when (selectedSystem) {
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
},
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(when (system) {
DifficultySystem.V_SCALE -> "V-Scale" DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font" DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS" DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom" DifficultySystem.CUSTOM -> "Custom"
}) },
}, onValueChange = {},
onClick = { readOnly = true,
selectedSystem = system trailingIcon = {
expanded = false ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
} },
modifier =
Modifier.menuAnchor(
type = MenuAnchorType.PrimaryNotEditable,
enabled = true
)
.width(120.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
usedSystems.forEach { system ->
DropdownMenuItem(
text = {
Text(
when (system) {
DifficultySystem.V_SCALE -> "V-Scale"
DifficultySystem.FONT -> "Font"
DifficultySystem.YDS -> "YDS"
DifficultySystem.CUSTOM -> "Custom"
}
)
},
onClick = {
selectedSystem = system
expanded = false
}
) )
} }
} }
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Filter progress data by selected scale // Filter grade distribution data by selected scale and time period
val filteredProgressData = remember(progressData, selectedSystem) { val filteredGradeData =
progressData.filter { it.difficultySystem == selectedSystem } remember(gradeDistributionData, selectedSystem, showAllTime) {
} val systemFiltered =
gradeDistributionData.filter {
if (filteredProgressData.isNotEmpty()) { it.difficultySystem == selectedSystem
val chartData = remember(filteredProgressData) { }
// Convert progress data to chart data points ordered by session
filteredProgressData if (showAllTime) {
.sortedBy { it.date } systemFiltered
.mapIndexed { index, p -> } else {
ChartDataPoint( // Filter for last 7 days
x = (index + 1).toFloat(), val sevenDaysAgo = LocalDateTime.now().minusDays(7)
y = p.maxGradeNumeric.toFloat(), systemFiltered.filter { dataPoint ->
label = "Session ${index + 1}" try {
) val attemptDate =
LocalDateTime.parse(
dataPoint.date,
DateTimeFormatter.ISO_LOCAL_DATE_TIME
)
attemptDate.isAfter(sevenDaysAgo)
} catch (e: Exception) {
// If date parsing fails, include the data point
true
}
}
} }
}
LineChart(
data = chartData,
modifier = Modifier.fillMaxWidth().height(220.dp),
xAxisFormatter = { value ->
"S${value.toInt()}" // S1, S2, S3, etc.
},
yAxisFormatter = { value ->
numericToGrade(selectedSystem, value.toInt())
} }
)
if (filteredGradeData.isNotEmpty()) {
// Group by grade and sum counts
val gradeGroups =
filteredGradeData
.groupBy { it.grade }
.mapValues { (_, dataPoints) -> dataPoints.sumOf { it.count } }
.map { (grade, count) ->
val firstDataPoint =
filteredGradeData.first { it.grade == grade }
BarChartDataPoint(
label = grade,
value = count,
gradeNumeric = firstDataPoint.gradeNumeric
)
}
BarChart(data = gradeGroups, modifier = Modifier.fillMaxWidth().height(220.dp))
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "X: session number, Y: max ${when(selectedSystem) { text =
"Successful climbs by ${when(selectedSystem) {
DifficultySystem.V_SCALE -> "V-grade" DifficultySystem.V_SCALE -> "V-grade"
DifficultySystem.FONT -> "Font grade" DifficultySystem.FONT -> "Font grade"
DifficultySystem.YDS -> "YDS grade" DifficultySystem.YDS -> "YDS grade"
DifficultySystem.CUSTOM -> "custom grade" DifficultySystem.CUSTOM -> "custom grade"
}} achieved", }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} else { } else {
Text( Column(
text = "No progress data available for ${when(selectedSystem) { modifier = Modifier.fillMaxWidth().height(220.dp),
DifficultySystem.V_SCALE -> "V-Scale" horizontalAlignment = Alignment.CenterHorizontally,
DifficultySystem.FONT -> "Font" verticalArrangement = Arrangement.Center
DifficultySystem.YDS -> "YDS" ) {
DifficultySystem.CUSTOM -> "Custom" Icon(
}} system", painter = painterResource(id = R.drawable.ic_mountains),
style = MaterialTheme.typography.bodyMedium, contentDescription = "No data",
color = MaterialTheme.colorScheme.onSurfaceVariant modifier = Modifier.size(48.dp),
) tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No data available.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text =
if (showAllTime)
"Complete some climbs to see your grade distribution!"
else "No climbs in the last 7 days",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
} }
} }
} }
} }
@Composable @Composable
fun FavoriteGymCard( fun FavoriteGymCard(gymName: String, sessionCount: Int) {
gymName: String, Card(modifier = Modifier.fillMaxWidth()) {
sessionCount: Int Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Favorite Gym", text = "Favorite Gym",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = gymName, text = gymName,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
if (sessionCount > 0) { if (sessionCount > 0) {
Text( Text(
text = "$sessionCount sessions", text = "$sessionCount sessions",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -307,74 +357,92 @@ fun FavoriteGymCard(
} }
@Composable @Composable
fun RecentActivityCard( fun RecentActivityCard(recentSessions: Int) {
recentSessions: Int Card(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text( Text(
text = "Recent Activity", text = "Recent Activity",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = if (recentSessions > 0) { text =
"You've had $recentSessions recent sessions" if (recentSessions > 0) {
} else { "You've had $recentSessions recent sessions"
"No recent activity" } else {
}, "No recent activity"
style = MaterialTheme.typography.bodyMedium },
style = MaterialTheme.typography.bodyMedium
) )
} }
} }
} }
data class ProgressDataPoint( data class GradeDistributionDataPoint(
val date: String, val date: String,
val maxGrade: String, val grade: String,
val maxGradeNumeric: Int, val gradeNumeric: Int,
val climbType: ClimbType, val count: Int,
val difficultySystem: DifficultySystem val climbType: ClimbType,
val difficultySystem: DifficultySystem
) )
fun calculateProgressOverTime( fun calculateGradeDistribution(
sessions: List<com.atridad.openclimb.data.model.ClimbSession>, sessions: List<com.atridad.openclimb.data.model.ClimbSession>,
problems: List<com.atridad.openclimb.data.model.Problem>, problems: List<com.atridad.openclimb.data.model.Problem>,
attempts: List<com.atridad.openclimb.data.model.Attempt> attempts: List<com.atridad.openclimb.data.model.Attempt>
): List<ProgressDataPoint> { ): List<GradeDistributionDataPoint> {
if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) { if (sessions.isEmpty() || problems.isEmpty() || attempts.isEmpty()) {
return emptyList() return emptyList()
} }
val sessionProgress = sessions.mapNotNull { session -> // Get all successful attempts
val sessionAttempts = attempts.filter { it.sessionId == session.id } val successfulAttempts =
if (sessionAttempts.isEmpty()) return@mapNotNull null attempts.filter {
val attemptedProblemIds = sessionAttempts.map { it.problemId }.distinct() it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
val attemptedProblems = problems.filter { it.id in attemptedProblemIds } }
if (attemptedProblems.isEmpty()) return@mapNotNull null
val highestGradeProblem = attemptedProblems.maxByOrNull { problem -> if (successfulAttempts.isEmpty()) {
gradeToNumeric(problem.difficulty.system, problem.difficulty.grade) return emptyList()
}
if (highestGradeProblem != null) {
ProgressDataPoint(
date = session.date,
maxGrade = highestGradeProblem.difficulty.grade,
maxGradeNumeric = gradeToNumeric(highestGradeProblem.difficulty.system, highestGradeProblem.difficulty.grade),
climbType = highestGradeProblem.climbType,
difficultySystem = highestGradeProblem.difficulty.system
)
} else null
} }
return sessionProgress.sortedBy { it.date }
// Map attempts to problems and create grade distribution data
val gradeDistribution = mutableMapOf<String, GradeDistributionDataPoint>()
successfulAttempts.forEach { attempt ->
val problem = problems.find { it.id == attempt.problemId }
val session = sessions.find { it.id == attempt.sessionId }
if (problem != null && session != null) {
val key = "${problem.difficulty.system.name}-${problem.difficulty.grade}"
val existing = gradeDistribution[key]
if (existing != null) {
gradeDistribution[key] = existing.copy(count = existing.count + 1)
} else {
gradeDistribution[key] =
GradeDistributionDataPoint(
date =
attempt.timestamp
.toString(), // Use attempt timestamp for filtering
grade = problem.difficulty.grade,
gradeNumeric =
gradeToNumeric(
problem.difficulty.system,
problem.difficulty.grade
),
count = 1,
climbType = problem.climbType,
difficultySystem = problem.difficulty.system
)
}
}
}
return gradeDistribution.values.toList()
} }
fun gradeToNumeric(system: DifficultySystem, grade: String): Int { fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
@@ -460,84 +528,3 @@ fun gradeToNumeric(system: DifficultySystem, grade: String): Int {
} }
} }
} }
fun numericToGrade(system: DifficultySystem, numeric: Int): String {
return when (system) {
DifficultySystem.V_SCALE -> {
when (numeric) {
0 -> "VB"
else -> "V$numeric"
}
}
DifficultySystem.FONT -> {
when (numeric) {
3 -> "3"
4 -> "4A"
5 -> "4B"
6 -> "4C"
7 -> "5A"
8 -> "5B"
9 -> "5C"
10 -> "6A"
11 -> "6A+"
12 -> "6B"
13 -> "6B+"
14 -> "6C"
15 -> "6C+"
16 -> "7A"
17 -> "7A+"
18 -> "7B"
19 -> "7B+"
20 -> "7C"
21 -> "7C+"
22 -> "8A"
23 -> "8A+"
24 -> "8B"
25 -> "8B+"
26 -> "8C"
27 -> "8C+"
else -> numeric.toString()
}
}
DifficultySystem.YDS -> {
when (numeric) {
50 -> "5.0"
51 -> "5.1"
52 -> "5.2"
53 -> "5.3"
54 -> "5.4"
55 -> "5.5"
56 -> "5.6"
57 -> "5.7"
58 -> "5.8"
59 -> "5.9"
60 -> "5.10a"
61 -> "5.10b"
62 -> "5.10c"
63 -> "5.10d"
64 -> "5.11a"
65 -> "5.11b"
66 -> "5.11c"
67 -> "5.11d"
68 -> "5.12a"
69 -> "5.12b"
70 -> "5.12c"
71 -> "5.12d"
72 -> "5.13a"
73 -> "5.13b"
74 -> "5.13c"
75 -> "5.13d"
76 -> "5.14a"
77 -> "5.14b"
78 -> "5.14c"
79 -> "5.14d"
80 -> "5.15a"
81 -> "5.15b"
82 -> "5.15c"
83 -> "5.15d"
else -> numeric.toString()
}
}
DifficultySystem.CUSTOM -> numeric.toString()
}
}

View File

@@ -21,181 +21,181 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProblemsScreen( fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
viewModel: ClimbViewModel,
onNavigateToProblemDetail: (String) -> Unit
) {
val problems by viewModel.problems.collectAsState() val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) } var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedImageIndex by remember { mutableIntStateOf(0) } var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state // Filter state
var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) } var selectedClimbType by remember { mutableStateOf<ClimbType?>(null) }
var selectedGym by remember { mutableStateOf<Gym?>(null) } var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters // Apply filters
val filteredProblems = problems.filter { problem -> val filteredProblems =
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false problems.filter { problem ->
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
climbTypeMatch && gymMatch val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
} climbTypeMatch && gymMatch
}
Column(
modifier = Modifier // Separate active and inactive problems
.fillMaxSize() val activeProblems = filteredProblems.filter { it.isActive }
.padding(16.dp) val inactiveProblems = filteredProblems.filter { !it.isActive }
) { val sortedProblems = activeProblems + inactiveProblems
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
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 = "Problems & Routes", text = "Problems & Routes",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Filters Section // Filters Section
if (problems.isNotEmpty()) { if (problems.isNotEmpty()) {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Column(modifier = Modifier.padding(16.dp)) {
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Filters", text = "Filters",
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))
// Climb Type Filter // Climb Type Filter
Text( Text(
text = "Climb Type", text = "Climb Type",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { item {
FilterChip( FilterChip(
onClick = { selectedClimbType = null }, onClick = { selectedClimbType = null },
label = { Text("All Types") }, label = { Text("All Types") },
selected = selectedClimbType == null selected = selectedClimbType == null
) )
} }
items(ClimbType.entries) { climbType -> items(ClimbType.entries) { climbType ->
FilterChip( FilterChip(
onClick = { selectedClimbType = climbType }, onClick = { selectedClimbType = climbType },
label = { Text(climbType.getDisplayName()) }, label = { Text(climbType.getDisplayName()) },
selected = selectedClimbType == climbType selected = selectedClimbType == climbType
) )
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Gym Filter // Gym Filter
Text( Text(
text = "Gym", text = "Gym",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
item { item {
FilterChip( FilterChip(
onClick = { selectedGym = null }, onClick = { selectedGym = null },
label = { Text("All Gyms") }, label = { Text("All Gyms") },
selected = selectedGym == null selected = selectedGym == null
) )
} }
items(gyms) { gym -> items(gyms) { gym ->
FilterChip( FilterChip(
onClick = { selectedGym = gym }, onClick = { selectedGym = gym },
label = { Text(gym.name) }, label = { Text(gym.name) },
selected = selectedGym?.id == gym.id selected = selectedGym?.id == gym.id
) )
} }
} }
// Filter result count // Filter result count
if (selectedClimbType != null || selectedGym != null) { if (selectedClimbType != null || selectedGym != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = "Showing ${filteredProblems.size} of ${problems.size} problems", text =
style = MaterialTheme.typography.bodySmall, "Showing ${filteredProblems.size} of ${problems.size} problems (${activeProblems.size} active, ${inactiveProblems.size} reset)",
color = MaterialTheme.colorScheme.onSurfaceVariant style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
if (filteredProblems.isEmpty()) { if (filteredProblems.isEmpty()) {
EmptyStateMessage( EmptyStateMessage(
title = if (problems.isEmpty()) { title =
if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet" if (problems.isEmpty()) {
} else { if (gyms.isEmpty()) "No Gyms Available" else "No Problems Yet"
"No Problems Match Filters" } else {
}, "No Problems Match Filters"
message = if (problems.isEmpty()) { },
if (gyms.isEmpty()) "Add a gym first to start tracking problems and routes!" else "Start tracking your favorite problems and routes!" message =
} else { if (problems.isEmpty()) {
"Try adjusting your filters to see more problems." if (gyms.isEmpty())
}, "Add a gym first to start tracking problems and routes!"
onActionClick = { }, else "Start tracking your favorite problems and routes!"
actionText = "" } else {
"Try adjusting your filters to see more problems."
},
onActionClick = {},
actionText = ""
) )
} else { } else {
LazyColumn { LazyColumn {
items(filteredProblems) { problem -> items(sortedProblems) { problem ->
ProblemCard( ProblemCard(
problem = problem, problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToProblemDetail(problem.id) }, onClick = { onNavigateToProblemDetail(problem.id) },
onImageClick = { imagePaths, index -> onImageClick = { imagePaths, index ->
selectedImagePaths = imagePaths selectedImagePaths = imagePaths
selectedImageIndex = index selectedImageIndex = index
showImageViewer = true showImageViewer = true
} },
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem)
}
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }
} }
// Fullscreen Image Viewer // Fullscreen Image Viewer
if (showImageViewer && selectedImagePaths.isNotEmpty()) { if (showImageViewer && selectedImagePaths.isNotEmpty()) {
FullscreenImageViewer( FullscreenImageViewer(
imagePaths = selectedImagePaths, imagePaths = selectedImagePaths,
initialIndex = selectedImageIndex, initialIndex = selectedImageIndex,
onDismiss = { showImageViewer = false } onDismiss = { showImageViewer = false }
) )
} }
} }
@@ -203,97 +203,117 @@ fun ProblemsScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProblemCard( fun ProblemCard(
problem: Problem, problem: Problem,
gymName: String, gymName: String,
onClick: () -> Unit, onClick: () -> Unit,
onImageClick: ((List<String>, Int) -> Unit)? = null onImageClick: ((List<String>, Int) -> Unit)? = null,
onToggleActive: (() -> Unit)? = null
) { ) {
Card( Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
onClick = onClick, Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = problem.name ?: "Unnamed Problem", text = problem.name ?: "Unnamed Problem",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color =
if (problem.isActive) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
) )
Text( Text(
text = gymName, text = gymName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (problem.isActive) 1f else 0.6f
)
) )
} }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Text(
text = problem.difficulty.grade, text = problem.difficulty.grade,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = problem.climbType.getDisplayName(), text = problem.climbType.getDisplayName(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
problem.location?.let { location -> problem.location?.let { location ->
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Location: $location", text = "Location: $location",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (problem.tags.isNotEmpty()) { if (problem.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row { Row {
problem.tags.take(3).forEach { tag -> problem.tags.take(3).forEach { tag ->
AssistChip( AssistChip(
onClick = { }, onClick = {},
label = { Text(tag) }, label = { Text(tag) },
modifier = Modifier.padding(end = 4.dp) modifier = Modifier.padding(end = 4.dp)
) )
} }
} }
} }
// Display images if any // Display images if any
if (problem.imagePaths.isNotEmpty()) { if (problem.imagePaths.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ImageDisplay( ImageDisplay(
imagePaths = problem.imagePaths.take(3), // Show max 3 images in list imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
imageSize = 60, imageSize = 60,
onImageClick = { index -> onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
onImageClick?.invoke(problem.imagePaths, index)
}
) )
} }
if (!problem.isActive) { if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Inactive", text = "Reset / No Longer Set",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
) )
} }
// Toggle active button
if (onToggleActive != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onToggleActive,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor =
if (problem.isActive)
MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (problem.isActive) "Mark as Reset" else "Mark as Active",
style = MaterialTheme.typography.bodySmall
)
}
}
} }
} }
} }

View File

@@ -324,12 +324,17 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() {
fun exportDataToZipUri(context: Context, uri: android.net.Uri) { fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = _uiState.value.copy(isLoading = true) _uiState.value =
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
repository.exportAllDataToZipUri(context, uri) repository.exportAllDataToZipUri(context, uri)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
message = "Data with images exported successfully" message =
"Export complete! Your climbing data and images have been saved."
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value =

View File

@@ -5,139 +5,146 @@ import android.content.Intent
import android.graphics.* import android.graphics.*
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.core.graphics.createBitmap
import androidx.core.graphics.toColorInt
object SessionShareUtils { object SessionShareUtils {
data class SessionStats( data class SessionStats(
val totalAttempts: Int, val totalAttempts: Int,
val successfulAttempts: Int, val successfulAttempts: Int,
val problems: List<Problem>, val problems: List<Problem>,
val uniqueProblemsAttempted: Int, val uniqueProblemsAttempted: Int,
val uniqueProblemsCompleted: Int, val uniqueProblemsCompleted: Int,
val averageGrade: String?, val averageGrade: String?,
val sessionDuration: String, val sessionDuration: String,
val topResult: AttemptResult?, val topResult: AttemptResult?,
val topGrade: String? val topGrade: String?
) )
fun calculateSessionStats( fun calculateSessionStats(
session: ClimbSession, session: ClimbSession,
attempts: List<Attempt>, attempts: List<Attempt>,
problems: List<Problem> problems: List<Problem>
): SessionStats { ): SessionStats {
val successfulResults = listOf( val successfulResults = listOf(AttemptResult.SUCCESS, AttemptResult.FLASH)
AttemptResult.SUCCESS,
AttemptResult.FLASH
)
val successfulAttempts = attempts.filter { it.result in successfulResults } val successfulAttempts = attempts.filter { it.result in successfulResults }
val uniqueProblems = attempts.map { it.problemId }.distinct() val uniqueProblems = attempts.map { it.problemId }.distinct()
val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct() val uniqueCompletedProblems = successfulAttempts.map { it.problemId }.distinct()
val attemptedProblems = problems.filter { it.id in uniqueProblems } val attemptedProblems = problems.filter { it.id in uniqueProblems }
// Calculate separate averages for different climbing types and difficulty systems // Calculate separate averages for different climbing types and difficulty systems
val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER } val boulderProblems = attemptedProblems.filter { it.climbType == ClimbType.BOULDER }
val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE } val ropeProblems = attemptedProblems.filter { it.climbType == ClimbType.ROPE }
val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder") val boulderAverage = calculateAverageGrade(boulderProblems, "Boulder")
val ropeAverage = calculateAverageGrade(ropeProblems, "Rope") val ropeAverage = calculateAverageGrade(ropeProblems, "Rope")
// Combine averages for display // Combine averages for display
val averageGrade = when { val averageGrade =
boulderAverage != null && ropeAverage != null -> "$boulderAverage / $ropeAverage" when {
boulderAverage != null -> boulderAverage boulderAverage != null && ropeAverage != null ->
ropeAverage != null -> ropeAverage "$boulderAverage / $ropeAverage"
else -> null boulderAverage != null -> boulderAverage
} ropeAverage != null -> ropeAverage
else -> null
}
// Determine highest achieved grade (only from completed problems: SUCCESS or FLASH) // Determine highest achieved grade (only from completed problems: SUCCESS or FLASH)
val completedProblems = problems.filter { it.id in uniqueCompletedProblems } val completedProblems = problems.filter { it.id in uniqueCompletedProblems }
val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER } val completedBoulder = completedProblems.filter { it.climbType == ClimbType.BOULDER }
val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE } val completedRope = completedProblems.filter { it.climbType == ClimbType.ROPE }
val topBoulder = highestGradeForProblems(completedBoulder) val topBoulder = highestGradeForProblems(completedBoulder)
val topRope = highestGradeForProblems(completedRope) val topRope = highestGradeForProblems(completedRope)
val topGrade = when { val topGrade =
topBoulder != null && topRope != null -> "$topBoulder / $topRope" when {
topBoulder != null -> topBoulder topBoulder != null && topRope != null -> "$topBoulder / $topRope"
topRope != null -> topRope topBoulder != null -> topBoulder
else -> null topRope != null -> topRope
} else -> null
}
val duration = if (session.duration != null) "${session.duration}m" else "Unknown" val duration = if (session.duration != null) "${session.duration}m" else "Unknown"
val topResult = attempts.maxByOrNull { val topResult =
when (it.result) { attempts
AttemptResult.FLASH -> 3 .maxByOrNull {
AttemptResult.SUCCESS -> 2 when (it.result) {
AttemptResult.FALL -> 1 AttemptResult.FLASH -> 3
else -> 0 AttemptResult.SUCCESS -> 2
} AttemptResult.FALL -> 1
}?.result else -> 0
}
}
?.result
return SessionStats( return SessionStats(
totalAttempts = attempts.size, totalAttempts = attempts.size,
successfulAttempts = successfulAttempts.size, successfulAttempts = successfulAttempts.size,
problems = attemptedProblems, problems = attemptedProblems,
uniqueProblemsAttempted = uniqueProblems.size, uniqueProblemsAttempted = uniqueProblems.size,
uniqueProblemsCompleted = uniqueCompletedProblems.size, uniqueProblemsCompleted = uniqueCompletedProblems.size,
averageGrade = averageGrade, averageGrade = averageGrade,
sessionDuration = duration, sessionDuration = duration,
topResult = topResult, topResult = topResult,
topGrade = topGrade topGrade = topGrade
) )
} }
/** /**
* Calculate average grade for a specific set of problems, respecting their difficulty systems * Calculate average grade for a specific set of problems, respecting their difficulty systems
*/ */
private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? { private fun calculateAverageGrade(problems: List<Problem>, climbingType: String): String? {
if (problems.isEmpty()) return null if (problems.isEmpty()) return null
// Group problems by difficulty system // Group problems by difficulty system
val problemsBySystem = problems.groupBy { it.difficulty.system } val problemsBySystem = problems.groupBy { it.difficulty.system }
val averages = mutableListOf<String>() val averages = mutableListOf<String>()
problemsBySystem.forEach { (system, systemProblems) -> problemsBySystem.forEach { (system, systemProblems) ->
when (system) { when (system) {
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
when { systemProblems.mapNotNull { problem ->
problem.difficulty.grade == "VB" -> 0 when {
else -> problem.difficulty.grade.removePrefix("V").toIntOrNull() problem.difficulty.grade == "VB" -> 0
} else -> problem.difficulty.grade.removePrefix("V").toIntOrNull()
} }
}
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt() val avg = gradeValues.average().roundToInt()
averages.add(if (avg == 0) "VB" else "V$avg") averages.add(if (avg == 0) "VB" else "V$avg")
} }
} }
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
// Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" -> 7) systemProblems.mapNotNull { problem ->
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull() // Extract numeric part from Font grades (e.g., "6A" -> 6, "7C+" ->
} // 7)
problem.difficulty.grade.filter { it.isDigit() }.toIntOrNull()
}
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average().roundToInt() val avg = gradeValues.average().roundToInt()
averages.add("$avg") averages.add("$avg")
} }
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
// Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10) systemProblems.mapNotNull { problem ->
val grade = problem.difficulty.grade // Extract numeric part from YDS grades (e.g., "5.10a" -> 5.10)
if (grade.startsWith("5.")) { val grade = problem.difficulty.grade
grade.substring(2).toDoubleOrNull() if (grade.startsWith("5.")) {
} else null grade.substring(2).toDoubleOrNull()
} } else null
}
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average() val avg = gradeValues.average()
averages.add("5.${String.format("%.1f", avg)}") averages.add("5.${String.format("%.1f", avg)}")
@@ -145,9 +152,13 @@ object SessionShareUtils {
} }
DifficultySystem.CUSTOM -> { DifficultySystem.CUSTOM -> {
// For custom systems, try to extract numeric values // For custom systems, try to extract numeric values
val gradeValues = systemProblems.mapNotNull { problem -> val gradeValues =
problem.difficulty.grade.filter { it.isDigit() || it == '.' || it == '-' }.toDoubleOrNull() systemProblems.mapNotNull { problem ->
} problem.difficulty
.grade
.filter { it.isDigit() || it == '.' || it == '-' }
.toDoubleOrNull()
}
if (gradeValues.isNotEmpty()) { if (gradeValues.isNotEmpty()) {
val avg = gradeValues.average() val avg = gradeValues.average()
averages.add(String.format("%.1f", avg)) averages.add(String.format("%.1f", avg))
@@ -155,7 +166,7 @@ object SessionShareUtils {
} }
} }
} }
return if (averages.isNotEmpty()) { return if (averages.isNotEmpty()) {
if (averages.size == 1) { if (averages.size == 1) {
averages.first() averages.first()
@@ -166,185 +177,262 @@ object SessionShareUtils {
} }
fun generateShareCard( fun generateShareCard(
context: Context, context: Context,
session: ClimbSession, session: ClimbSession,
gym: Gym, gym: Gym,
stats: SessionStats stats: SessionStats
): File? { ): File? {
return try { return try {
val width = 1242 // 3:4 aspect at higher resolution for better fit val width = 1242 // 3:4 aspect at higher resolution for better fit
val height = 1656 val height = 1656
val bitmap = createBitmap(width, height) val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
val gradientDrawable = GradientDrawable( val gradientDrawable =
GradientDrawable.Orientation.TOP_BOTTOM, GradientDrawable(
intArrayOf( GradientDrawable.Orientation.TOP_BOTTOM,
"#667eea".toColorInt(), intArrayOf("#667eea".toColorInt(), "#764ba2".toColorInt())
"#764ba2".toColorInt() )
)
)
gradientDrawable.setBounds(0, 0, width, height) gradientDrawable.setBounds(0, 0, width, height)
gradientDrawable.draw(canvas) gradientDrawable.draw(canvas)
// Setup paint objects // Setup paint objects
val titlePaint = Paint().apply { val titlePaint =
color = Color.WHITE Paint().apply {
textSize = 72f color = Color.WHITE
typeface = Typeface.DEFAULT_BOLD textSize = 72f
isAntiAlias = true typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER isAntiAlias = true
} textAlign = Paint.Align.CENTER
}
val subtitlePaint = Paint().apply {
color = "#E8E8E8".toColorInt() val subtitlePaint =
textSize = 48f Paint().apply {
typeface = Typeface.DEFAULT color = "#E8E8E8".toColorInt()
isAntiAlias = true textSize = 48f
textAlign = Paint.Align.CENTER typeface = Typeface.DEFAULT
} isAntiAlias = true
textAlign = Paint.Align.CENTER
val statLabelPaint = Paint().apply { }
color = "#B8B8B8".toColorInt()
textSize = 36f val statLabelPaint =
typeface = Typeface.DEFAULT Paint().apply {
isAntiAlias = true color = "#B8B8B8".toColorInt()
textAlign = Paint.Align.CENTER textSize = 36f
} typeface = Typeface.DEFAULT
isAntiAlias = true
val statValuePaint = Paint().apply { textAlign = Paint.Align.CENTER
color = Color.WHITE }
textSize = 64f
typeface = Typeface.DEFAULT_BOLD val statValuePaint =
isAntiAlias = true Paint().apply {
textAlign = Paint.Align.CENTER color = Color.WHITE
} textSize = 64f
typeface = Typeface.DEFAULT_BOLD
val cardPaint = Paint().apply { isAntiAlias = true
color = "#40FFFFFF".toColorInt() textAlign = Paint.Align.CENTER
isAntiAlias = true }
}
val cardPaint =
Paint().apply {
color = "#40FFFFFF".toColorInt()
isAntiAlias = true
}
// Draw main card background // Draw main card background
val cardRect = RectF(60f, 200f, width - 60f, height - 120f) val cardRect = RectF(60f, 200f, width - 60f, height - 120f)
canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint) canvas.drawRoundRect(cardRect, 40f, 40f, cardPaint)
// Draw content // Draw content
var yPosition = 300f var yPosition = 300f
// Title // Title
canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint) canvas.drawText("Climbing Session", width / 2f, yPosition, titlePaint)
yPosition += 80f yPosition += 80f
// Gym and date // Gym and date
canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint) canvas.drawText(gym.name, width / 2f, yPosition, subtitlePaint)
yPosition += 60f yPosition += 60f
val dateText = formatSessionDate(session.date) val dateText = formatSessionDate(session.date)
canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint) canvas.drawText(dateText, width / 2f, yPosition, subtitlePaint)
yPosition += 120f yPosition += 120f
// Stats grid // Stats grid
val statsStartY = yPosition val statsStartY = yPosition
val columnWidth = width / 2f val columnWidth = width / 2f
val columnMaxTextWidth = columnWidth - 120f val columnMaxTextWidth = columnWidth - 120f
// Left column stats // Left column stats
var leftY = statsStartY var leftY = statsStartY
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Attempts", stats.totalAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Attempts",
stats.totalAttempts.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Problems", stats.uniqueProblemsAttempted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Problems",
stats.uniqueProblemsAttempted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
leftY += 120f leftY += 120f
drawStatItemFitting(canvas, columnWidth / 2f, leftY, "Duration", stats.sessionDuration, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
columnWidth / 2f,
leftY,
"Duration",
stats.sessionDuration,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
// Right column stats // Right column stats
var rightY = statsStartY var rightY = statsStartY
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Successful", stats.successfulAttempts.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Completed",
stats.uniqueProblemsCompleted.toString(),
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightY += 120f rightY += 120f
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Completed", stats.uniqueProblemsCompleted.toString(), statLabelPaint, statValuePaint, columnMaxTextWidth)
rightY += 120f
var rightYAfter = rightY var rightYAfter = rightY
stats.topGrade?.let { grade -> stats.topGrade?.let { grade ->
drawStatItemFitting(canvas, width - columnWidth / 2f, rightY, "Top Grade", grade, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rightY,
"Top Grade",
grade,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
rightYAfter += 120f rightYAfter += 120f
} }
// Grade range(s) // Grade range(s)
val boulderRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.BOULDER }) val boulderRange =
val ropeRange = gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE }) gradeRangeForProblems(
stats.problems.filter { it.climbType == ClimbType.BOULDER }
)
val ropeRange =
gradeRangeForProblems(stats.problems.filter { it.climbType == ClimbType.ROPE })
val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f val rangesY = kotlin.math.max(leftY, rightYAfter) + 120f
if (boulderRange != null && ropeRange != null) { if (boulderRange != null && ropeRange != null) {
// Two evenly spaced items // Two evenly spaced items
drawStatItemFitting(canvas, columnWidth / 2f, rangesY, "Boulder Range", boulderRange, statLabelPaint, statValuePaint, columnMaxTextWidth) drawStatItemFitting(
drawStatItemFitting(canvas, width - columnWidth / 2f, rangesY, "Rope Range", ropeRange, statLabelPaint, statValuePaint, columnMaxTextWidth) canvas,
columnWidth / 2f,
rangesY,
"Boulder Range",
boulderRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
drawStatItemFitting(
canvas,
width - columnWidth / 2f,
rangesY,
"Rope Range",
ropeRange,
statLabelPaint,
statValuePaint,
columnMaxTextWidth
)
} else if (boulderRange != null || ropeRange != null) { } else if (boulderRange != null || ropeRange != null) {
// Single centered item // Single centered item
val singleRange = boulderRange ?: ropeRange ?: "" val singleRange = boulderRange ?: ropeRange ?: ""
drawStatItemFitting(canvas, width / 2f, rangesY, "Grade Range", singleRange, statLabelPaint, statValuePaint, width - 200f) drawStatItemFitting(
canvas,
width / 2f,
rangesY,
"Grade Range",
singleRange,
statLabelPaint,
statValuePaint,
width - 200f
)
} }
// App branding // App branding
val brandingPaint = Paint().apply { val brandingPaint =
color = "#80FFFFFF".toColorInt() Paint().apply {
textSize = 32f color = "#80FFFFFF".toColorInt()
typeface = Typeface.DEFAULT textSize = 32f
isAntiAlias = true typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER isAntiAlias = true
} textAlign = Paint.Align.CENTER
}
canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint) canvas.drawText("OpenClimb", width / 2f, height - 40f, brandingPaint)
// Save to file // Save to file
val shareDir = File(context.cacheDir, "shares") val shareDir = File(context.cacheDir, "shares")
if (!shareDir.exists()) { if (!shareDir.exists()) {
shareDir.mkdirs() shareDir.mkdirs()
} }
val filename = "session_${session.id}_${System.currentTimeMillis()}.png" val filename = "session_${session.id}_${System.currentTimeMillis()}.png"
val file = File(shareDir, filename) val file = File(shareDir, filename)
val outputStream = FileOutputStream(file) val outputStream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.flush() outputStream.flush()
outputStream.close() outputStream.close()
bitmap.recycle() bitmap.recycle()
file file
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null
} }
} }
private fun drawStatItem( private fun drawStatItem(
canvas: Canvas, canvas: Canvas,
x: Float, x: Float,
y: Float, y: Float,
label: String, label: String,
value: String, value: String,
labelPaint: Paint, labelPaint: Paint,
valuePaint: Paint valuePaint: Paint
) { ) {
canvas.drawText(value, x, y, valuePaint) canvas.drawText(value, x, y, valuePaint)
canvas.drawText(label, x, y + 50f, labelPaint) canvas.drawText(label, x, y + 50f, labelPaint)
} }
/** /**
* Draws a stat item while fitting the value text to a max width by reducing text size if needed. * Draws a stat item while fitting the value text to a max width by reducing text size if
* needed.
*/ */
private fun drawStatItemFitting( private fun drawStatItemFitting(
canvas: Canvas, canvas: Canvas,
x: Float, x: Float,
y: Float, y: Float,
label: String, label: String,
value: String, value: String,
labelPaint: Paint, labelPaint: Paint,
valuePaint: Paint, valuePaint: Paint,
maxTextWidth: Float maxTextWidth: Float
) { ) {
val tempPaint = Paint(valuePaint) val tempPaint = Paint(valuePaint)
var textSize = tempPaint.textSize var textSize = tempPaint.textSize
@@ -357,7 +445,7 @@ object SessionShareUtils {
canvas.drawText(value, x, y, tempPaint) canvas.drawText(value, x, y, tempPaint)
canvas.drawText(label, x, y + 50f, labelPaint) canvas.drawText(label, x, y + 50f, labelPaint)
} }
/** /**
* Returns a range string like "X - Y" for the given problems, based on their difficulty grades. * Returns a range string like "X - Y" for the given problems, based on their difficulty grades.
*/ */
@@ -367,9 +455,7 @@ object SessionShareUtils {
val sorted = grades.sortedWith { a, b -> a.compareTo(b) } val sorted = grades.sortedWith { a, b -> a.compareTo(b) }
return "${sorted.first().grade} - ${sorted.last().grade}" return "${sorted.first().grade} - ${sorted.last().grade}"
} }
private fun formatSessionDate(dateString: String): String { private fun formatSessionDate(dateString: String): String {
return try { return try {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
@@ -380,23 +466,28 @@ object SessionShareUtils {
dateString.take(10) dateString.take(10)
} }
} }
fun shareSessionCard(context: Context, imageFile: File) { fun shareSessionCard(context: Context, imageFile: File) {
try { try {
val uri = FileProvider.getUriForFile( val uri =
context, FileProvider.getUriForFile(
"${context.packageName}.fileprovider", context,
imageFile "${context.packageName}.fileprovider",
) imageFile
)
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND val shareIntent =
type = "image/png" Intent().apply {
putExtra(Intent.EXTRA_STREAM, uri) action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! 🧗‍♀️ #OpenClimb") type = "image/png"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, uri)
} putExtra(
Intent.EXTRA_TEXT,
"Check out my climbing session! 🧗‍♀️ #OpenClimb"
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share Session") val chooser = Intent.createChooser(shareIntent, "Share Session")
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser) context.startActivity(chooser)
@@ -406,16 +497,18 @@ object SessionShareUtils {
} }
/** /**
* Returns the highest grade string among the given problems, respecting their difficulty system. * Returns the highest grade string among the given problems, respecting their difficulty
* system.
*/ */
private fun highestGradeForProblems(problems: List<Problem>): String? { private fun highestGradeForProblems(problems: List<Problem>): String? {
if (problems.isEmpty()) return null if (problems.isEmpty()) return null
return problems.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }?.difficulty?.grade return problems
.maxByOrNull { p -> gradeRank(p.difficulty.system, p.difficulty.grade) }
?.difficulty
?.grade
} }
/** /** Produces a comparable numeric rank for grades across supported systems. */
* Produces a comparable numeric rank for grades across supported systems.
*/
private fun gradeRank(system: DifficultySystem, grade: String): Double { private fun gradeRank(system: DifficultySystem, grade: String): Double {
return when (system) { return when (system) {
DifficultySystem.V_SCALE -> { DifficultySystem.V_SCALE -> {
@@ -424,7 +517,8 @@ object SessionShareUtils {
DifficultySystem.FONT -> { DifficultySystem.FONT -> {
val list = DifficultySystem.FONT.getAvailableGrades() val list = DifficultySystem.FONT.getAvailableGrades()
val idx = list.indexOf(grade.uppercase()) val idx = list.indexOf(grade.uppercase())
if (idx >= 0) idx.toDouble() else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0 if (idx >= 0) idx.toDouble()
else grade.filter { it.isDigit() }.toDoubleOrNull() ?: -1.0
} }
DifficultySystem.YDS -> { DifficultySystem.YDS -> {
// Parse 5.X with optional letter a-d // Parse 5.X with optional letter a-d
@@ -434,13 +528,14 @@ object SessionShareUtils {
val numberPart = tail.takeWhile { it.isDigit() || it == '.' } val numberPart = tail.takeWhile { it.isDigit() || it == '.' }
val letterPart = tail.drop(numberPart.length).firstOrNull() val letterPart = tail.drop(numberPart.length).firstOrNull()
val base = numberPart.toDoubleOrNull() ?: return -1.0 val base = numberPart.toDoubleOrNull() ?: return -1.0
val letterWeight = when (letterPart) { val letterWeight =
'a' -> 0.0 when (letterPart) {
'b' -> 0.1 'a' -> 0.0
'c' -> 0.2 'b' -> 0.1
'd' -> 0.3 'c' -> 0.2
else -> 0.0 'd' -> 0.3
} else -> 0.0
}
base + letterWeight base + letterWeight
} }
DifficultySystem.CUSTOM -> { DifficultySystem.CUSTOM -> {

View File

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

View File

@@ -394,7 +394,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -414,7 +414,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.2; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -437,7 +437,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -457,7 +457,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.2; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -479,7 +479,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -490,7 +490,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.2; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -509,7 +509,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -520,7 +520,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.2; MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2FE948A2E78FEE0008CDB25"
BuildableName = "SessionStatusLiveExtension.appex"
BlueprintName = "SessionStatusLiveExtension"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -7,12 +7,25 @@
<key>OpenClimb.xcscheme_^#shared#^_</key> <key>OpenClimb.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
<key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key> <key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>D24C19672E75002A0045894C</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D2FE948A2E78FEE0008CDB25</key>
<dict>
<key>primary</key>
<true/>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@@ -4,6 +4,7 @@ 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 @Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = []
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@@ -43,11 +44,23 @@ struct ContentView: View {
.tag(4) .tag(4)
} }
.environmentObject(dataManager) .environmentObject(dataManager)
.onChange(of: scenePhase) { .onChange(of: scenePhase) { oldPhase, newPhase in
if scenePhase == .active { if newPhase == .active {
dataManager.onAppBecomeActive() // Add slight delay to ensure app is fully loaded
Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
dataManager.onAppBecomeActive()
}
} else if newPhase == .background {
dataManager.onAppEnterBackground()
} }
} }
.onAppear {
setupNotificationObservers()
}
.onDisappear {
removeNotificationObservers()
}
.overlay(alignment: .top) { .overlay(alignment: .top) {
if let message = dataManager.successMessage { if let message = dataManager.successMessage {
SuccessMessageView(message: message) SuccessMessageView(message: message)
@@ -62,6 +75,44 @@ struct ContentView: View {
} }
} }
} }
private func setupNotificationObservers() {
// Listen for when the app will enter foreground
let willEnterForegroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { _ in
print("📱 App will enter foreground - preparing Live Activity check")
Task {
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive()
}
}
// Listen for when the app becomes active
let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
print("📱 App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
dataManager.onAppBecomeActive()
}
}
notificationObservers = [willEnterForegroundObserver, didBecomeActiveObserver]
}
private func removeNotificationObservers() {
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
notificationObservers.removeAll()
}
} }
struct SuccessMessageView: View { struct SuccessMessageView: View {

View File

@@ -260,7 +260,7 @@ struct Problem: Identifiable, Codable, Hashable {
let description: String? let description: String?
let climbType: ClimbType let climbType: ClimbType
let difficulty: DifficultyGrade let difficulty: DifficultyGrade
let setter: String?
let tags: [String] let tags: [String]
let location: String? let location: String?
let imagePaths: [String] let imagePaths: [String]
@@ -272,7 +272,7 @@ struct Problem: Identifiable, Codable, Hashable {
init( init(
gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType, gymId: UUID, name: String? = nil, description: String? = nil, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String? = nil, tags: [String] = [], difficulty: DifficultyGrade, tags: [String] = [],
location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil, location: String? = nil, imagePaths: [String] = [], dateSet: Date? = nil,
notes: String? = nil notes: String? = nil
) { ) {
@@ -282,7 +282,7 @@ struct Problem: Identifiable, Codable, Hashable {
self.description = description self.description = description
self.climbType = climbType self.climbType = climbType
self.difficulty = difficulty self.difficulty = difficulty
self.setter = setter
self.tags = tags self.tags = tags
self.location = location self.location = location
self.imagePaths = imagePaths self.imagePaths = imagePaths
@@ -296,7 +296,7 @@ struct Problem: Identifiable, Codable, Hashable {
func updated( func updated(
name: String? = nil, description: String? = nil, climbType: ClimbType? = nil, name: String? = nil, description: String? = nil, climbType: ClimbType? = nil,
difficulty: DifficultyGrade? = nil, setter: String? = nil, tags: [String]? = nil, difficulty: DifficultyGrade? = nil, tags: [String]? = nil,
location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil, location: String? = nil, imagePaths: [String]? = nil, isActive: Bool? = nil,
dateSet: Date? = nil, notes: String? = nil dateSet: Date? = nil, notes: String? = nil
) -> Problem { ) -> Problem {
@@ -307,7 +307,7 @@ struct Problem: Identifiable, Codable, Hashable {
description: description ?? self.description, description: description ?? self.description,
climbType: climbType ?? self.climbType, climbType: climbType ?? self.climbType,
difficulty: difficulty ?? self.difficulty, difficulty: difficulty ?? self.difficulty,
setter: setter ?? self.setter,
tags: tags ?? self.tags, tags: tags ?? self.tags,
location: location ?? self.location, location: location ?? self.location,
imagePaths: imagePaths ?? self.imagePaths, imagePaths: imagePaths ?? self.imagePaths,
@@ -321,7 +321,7 @@ struct Problem: Identifiable, Codable, Hashable {
private init( private init(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, difficulty: DifficultyGrade, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date updatedAt: Date
) { ) {
@@ -331,7 +331,7 @@ struct Problem: Identifiable, Codable, Hashable {
self.description = description self.description = description
self.climbType = climbType self.climbType = climbType
self.difficulty = difficulty self.difficulty = difficulty
self.setter = setter
self.tags = tags self.tags = tags
self.location = location self.location = location
self.imagePaths = imagePaths self.imagePaths = imagePaths
@@ -344,7 +344,7 @@ struct Problem: Identifiable, Codable, Hashable {
static func fromImport( static func fromImport(
id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType, id: UUID, gymId: UUID, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, setter: String?, tags: [String], location: String?, difficulty: DifficultyGrade, tags: [String], location: String?,
imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date, imagePaths: [String], isActive: Bool, dateSet: Date?, notes: String?, createdAt: Date,
updatedAt: Date updatedAt: Date
) -> Problem { ) -> Problem {
@@ -355,7 +355,7 @@ struct Problem: Identifiable, Codable, Hashable {
description: description, description: description,
climbType: climbType, climbType: climbType,
difficulty: difficulty, difficulty: difficulty,
setter: setter,
tags: tags, tags: tags,
location: location, location: location,
imagePaths: imagePaths, imagePaths: imagePaths,

View File

@@ -1,4 +1,3 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -522,7 +521,7 @@ class ImageManager {
} }
} }
private func getFullPath(from relativePath: String) -> String { func getFullPath(from relativePath: String) -> String {
// If it's already a full path, check if it's legacy and needs migration // If it's already a full path, check if it's legacy and needs migration
if relativePath.hasPrefix("/") { if relativePath.hasPrefix("/") {
// If it's pointing to legacy Documents directory, redirect to new location // If it's pointing to legacy Documents directory, redirect to new location

View File

@@ -7,6 +7,10 @@ import UniformTypeIdentifiers
import WidgetKit import WidgetKit
#endif #endif
#if canImport(ActivityKit)
import ActivityKit
#endif
@MainActor @MainActor
class ClimbingDataManager: ObservableObject { class ClimbingDataManager: ObservableObject {
@@ -23,6 +27,7 @@ class ClimbingDataManager: ObservableObject {
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") 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()
private var liveActivityObserver: NSObjectProtocol?
private enum Keys { private enum Keys {
static let gyms = "openclimb_gyms" static let gyms = "openclimb_gyms"
@@ -57,6 +62,7 @@ class ClimbingDataManager: ObservableObject {
_ = ImageManager.shared _ = ImageManager.shared
loadAllData() loadAllData()
migrateImagePaths() migrateImagePaths()
setupLiveActivityNotifications()
Task { Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
@@ -67,6 +73,12 @@ class ClimbingDataManager: ObservableObject {
} }
} }
deinit {
if let observer = liveActivityObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func loadAllData() { private func loadAllData() {
loadGyms() loadGyms()
loadProblems() loadProblems()
@@ -463,6 +475,7 @@ class ClimbingDataManager: ObservableObject {
let exportData = ClimbDataExport( let exportData = ClimbDataExport(
exportedAt: dateFormatter.string(from: Date()), exportedAt: dateFormatter.string(from: Date()),
version: "2.0",
gyms: gyms.map { AndroidGym(from: $0) }, gyms: gyms.map { AndroidGym(from: $0) },
problems: problems.map { AndroidProblem(from: $0) }, problems: problems.map { AndroidProblem(from: $0) },
sessions: sessions.map { AndroidClimbSession(from: $0) }, sessions: sessions.map { AndroidClimbSession(from: $0) },
@@ -471,13 +484,21 @@ class ClimbingDataManager: ObservableObject {
// Collect referenced image paths // Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths() let referencedImagePaths = collectReferencedImagePaths()
print("🎯 Starting export with \(referencedImagePaths.count) images")
return try ZipUtils.createExportZip( let zipData = try ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("✅ Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay()
return zipData
} catch { } catch {
setError("Export failed: \(error.localizedDescription)") let errorMessage = "Export failed: \(error.localizedDescription)"
print("\(errorMessage)")
setError(errorMessage)
return nil return nil
} }
} }
@@ -565,16 +586,18 @@ class ClimbingDataManager: ObservableObject {
struct ClimbDataExport: Codable { struct ClimbDataExport: Codable {
let exportedAt: String let exportedAt: String
let version: String
let gyms: [AndroidGym] let gyms: [AndroidGym]
let problems: [AndroidProblem] let problems: [AndroidProblem]
let sessions: [AndroidClimbSession] let sessions: [AndroidClimbSession]
let attempts: [AndroidAttempt] let attempts: [AndroidAttempt]
init( init(
exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem], exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem],
sessions: [AndroidClimbSession], attempts: [AndroidAttempt] sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
) { ) {
self.exportedAt = exportedAt self.exportedAt = exportedAt
self.version = version
self.gyms = gyms self.gyms = gyms
self.problems = problems self.problems = problems
self.sessions = sessions self.sessions = sessions
@@ -588,6 +611,7 @@ struct AndroidGym: Codable {
let location: String? let location: String?
let supportedClimbTypes: [ClimbType] let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem] let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String? let notes: String?
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
@@ -598,6 +622,7 @@ struct AndroidGym: Codable {
self.location = gym.location self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems self.difficultySystems = gym.difficultySystems
self.customDifficultyGrades = gym.customDifficultyGrades
self.notes = gym.notes self.notes = gym.notes
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
@@ -607,13 +632,15 @@ struct AndroidGym: Codable {
init( init(
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
notes: String?, createdAt: String, updatedAt: String
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.location = location self.location = location
self.supportedClimbTypes = supportedClimbTypes self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes self.notes = notes
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
@@ -633,7 +660,7 @@ struct AndroidGym: Codable {
location: location, location: location,
supportedClimbTypes: supportedClimbTypes, supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems, difficultySystems: difficultySystems,
customDifficultyGrades: [], customDifficultyGrades: customDifficultyGrades,
notes: notes, notes: notes,
createdAt: createdDate, createdAt: createdDate,
updatedAt: updatedDate updatedAt: updatedDate
@@ -648,7 +675,12 @@ struct AndroidProblem: Codable {
let description: String? let description: String?
let climbType: ClimbType let climbType: ClimbType
let difficulty: DifficultyGrade let difficulty: DifficultyGrade
let tags: [String]
let location: String?
let imagePaths: [String]? let imagePaths: [String]?
let isActive: Bool
let dateSet: String?
let notes: String?
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
@@ -659,16 +691,25 @@ struct AndroidProblem: Codable {
self.description = problem.description self.description = problem.description
self.climbType = problem.climbType self.climbType = problem.climbType
self.difficulty = problem.difficulty self.difficulty = problem.difficulty
self.tags = problem.tags
self.location = problem.location
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
self.isActive = problem.isActive
self.notes = problem.notes
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil
self.createdAt = formatter.string(from: problem.createdAt) self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt) self.updatedAt = formatter.string(from: problem.updatedAt)
} }
init( init(
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String difficulty: DifficultyGrade, tags: [String] = [],
location: String? = nil,
imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil,
notes: String? = nil,
createdAt: String, updatedAt: String
) { ) {
self.id = id self.id = id
self.gymId = gymId self.gymId = gymId
@@ -676,7 +717,12 @@ struct AndroidProblem: Codable {
self.description = description self.description = description
self.climbType = climbType self.climbType = climbType
self.difficulty = difficulty self.difficulty = difficulty
self.tags = tags
self.location = location
self.imagePaths = imagePaths self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
@@ -697,13 +743,12 @@ struct AndroidProblem: Codable {
description: description, description: description,
climbType: climbType, climbType: climbType,
difficulty: difficulty, difficulty: difficulty,
setter: nil, tags: tags,
tags: [], location: location,
location: nil,
imagePaths: imagePaths ?? [], imagePaths: imagePaths ?? [],
isActive: true, isActive: isActive,
dateSet: nil, dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil,
notes: nil, notes: notes,
createdAt: createdDate, createdAt: createdDate,
updatedAt: updatedDate updatedAt: updatedDate
) )
@@ -717,7 +762,12 @@ struct AndroidProblem: Codable {
description: self.description, description: self.description,
climbType: self.climbType, climbType: self.climbType,
difficulty: self.difficulty, difficulty: self.difficulty,
tags: self.tags,
location: self.location,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
isActive: self.isActive,
dateSet: self.dateSet,
notes: self.notes,
createdAt: self.createdAt, createdAt: self.createdAt,
updatedAt: self.updatedAt updatedAt: self.updatedAt
) )
@@ -730,8 +780,9 @@ struct AndroidClimbSession: Codable {
let date: String let date: String
let startTime: String? let startTime: String?
let endTime: String? let endTime: String?
let duration: Int? let duration: Int64?
let status: SessionStatus let status: SessionStatus
let notes: String?
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
@@ -743,15 +794,17 @@ struct AndroidClimbSession: Codable {
self.date = formatter.string(from: session.date) self.date = formatter.string(from: session.date)
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
self.duration = session.duration self.duration = session.duration != nil ? Int64(session.duration!) : nil
self.status = session.status self.status = session.status
self.notes = session.notes
self.createdAt = formatter.string(from: session.createdAt) self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt) self.updatedAt = formatter.string(from: session.updatedAt)
} }
init( init(
id: String, gymId: String, date: String, startTime: String?, endTime: String?, id: String, gymId: String, date: String, startTime: String?, endTime: String?,
duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String,
updatedAt: String
) { ) {
self.id = id self.id = id
self.gymId = gymId self.gymId = gymId
@@ -760,6 +813,7 @@ struct AndroidClimbSession: Codable {
self.endTime = endTime self.endTime = endTime
self.duration = duration self.duration = duration
self.status = status self.status = status
self.notes = notes
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
@@ -783,9 +837,9 @@ struct AndroidClimbSession: Codable {
date: sessionDate, date: sessionDate,
startTime: sessionStartTime, startTime: sessionStartTime,
endTime: sessionEndTime, endTime: sessionEndTime,
duration: duration, duration: duration != nil ? Int(duration!) : nil,
status: status, status: status,
notes: nil, notes: notes,
createdAt: createdDate, createdAt: createdDate,
updatedAt: updatedDate updatedAt: updatedDate
) )
@@ -799,8 +853,8 @@ struct AndroidAttempt: Codable {
let result: AttemptResult let result: AttemptResult
let highestHold: String? let highestHold: String?
let notes: String? let notes: String?
let duration: Int? let duration: Int64?
let restTime: Int? let restTime: Int64?
let timestamp: String let timestamp: String
let createdAt: String let createdAt: String
@@ -811,8 +865,8 @@ struct AndroidAttempt: Codable {
self.result = attempt.result self.result = attempt.result
self.highestHold = attempt.highestHold self.highestHold = attempt.highestHold
self.notes = attempt.notes self.notes = attempt.notes
self.duration = attempt.duration self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil
self.restTime = attempt.restTime self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.timestamp = formatter.string(from: attempt.timestamp) self.timestamp = formatter.string(from: attempt.timestamp)
@@ -821,7 +875,7 @@ struct AndroidAttempt: Codable {
init( init(
id: String, sessionId: String, problemId: String, result: AttemptResult, id: String, sessionId: String, problemId: String, result: AttemptResult,
highestHold: String?, notes: String?, duration: Int?, restTime: Int?, highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?,
timestamp: String, createdAt: String timestamp: String, createdAt: String
) { ) {
self.id = id self.id = id
@@ -853,8 +907,8 @@ struct AndroidAttempt: Codable {
result: result, result: result,
highestHold: highestHold, highestHold: highestHold,
notes: notes, notes: notes,
duration: duration, duration: duration != nil ? Int(duration!) : nil,
restTime: restTime, restTime: restTime != nil ? Int(restTime!) : nil,
timestamp: attemptTimestamp, timestamp: attemptTimestamp,
createdAt: createdDate createdAt: createdDate
) )
@@ -864,9 +918,33 @@ struct AndroidAttempt: Codable {
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>() var imagePaths = Set<String>()
print("🖼️ Starting image path collection...")
print("📊 Total problems: \(problems.count)")
for problem in problems { for problem in problems {
imagePaths.formUnion(problem.imagePaths) if !problem.imagePaths.isEmpty {
print(
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
let fullPath = ImageManager.shared.getFullPath(from: imagePath)
print(" - Full path: \(fullPath)")
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" ✅ File exists")
imagePaths.insert(fullPath)
} else {
print(" ❌ File does NOT exist")
// Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath)
}
}
}
} }
print("🖼️ Collected \(imagePaths.count) total image paths for export")
return imagePaths return imagePaths
} }
@@ -1046,23 +1124,111 @@ extension ClimbingDataManager {
} }
private func checkAndRestartLiveActivity() async { private func checkAndRestartLiveActivity() async {
guard let activeSession = activeSession else { return } guard let activeSession = activeSession else {
// No active session, make sure all Live Activities are cleaned up
await LiveActivityManager.shared.endLiveActivity()
return
}
// Only restart if session is actually active
guard activeSession.status == .active else {
print(
"⚠️ Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
)
await LiveActivityManager.shared.endLiveActivity()
return
}
if let gym = gym(withId: activeSession.gymId) { if let gym = gym(withId: activeSession.gymId) {
print("🔍 Checking Live Activity for active session at \(gym.name)")
// First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities()
// Then attempt to restart if needed
await LiveActivityManager.shared.restartLiveActivityIfNeeded( await LiveActivityManager.shared.restartLiveActivityIfNeeded(
activeSession: activeSession, activeSession: activeSession,
gymName: gym.name gymName: gym.name
) )
// Update with current session data
await updateLiveActivityData()
} }
} }
/// Call this when app becomes active to check for Live Activity restart /// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() { func onAppBecomeActive() {
print("📱 App became active - checking Live Activity status")
Task { Task {
await checkAndRestartLiveActivity() await checkAndRestartLiveActivity()
} }
} }
/// Call this when app enters background to update Live Activity
func onAppEnterBackground() {
print("📱 App entering background - updating Live Activity if needed")
Task {
await updateLiveActivityData()
}
}
/// Setup notifications for Live Activity events
private func setupLiveActivityNotifications() {
liveActivityObserver = NotificationCenter.default.addObserver(
forName: .liveActivityDismissed,
object: nil,
queue: .main
) { [weak self] _ in
print("🔔 Received Live Activity dismissed notification - attempting restart")
Task { @MainActor in
await self?.handleLiveActivityDismissed()
}
}
}
/// Handle Live Activity being dismissed by user
private func handleLiveActivityDismissed() async {
guard let activeSession = activeSession,
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
return
}
print("🔄 Attempting to restart dismissed Live Activity for \(gym.name)")
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
await LiveActivityManager.shared.startLiveActivity(
for: activeSession,
gymName: gym.name
)
// Update with current data
await updateLiveActivityData()
}
/// Update Live Activity with current session statistics
private func updateLiveActivityData() async {
guard let activeSession = activeSession,
activeSession.status == .active
else { return }
let elapsed = Date().timeIntervalSince(activeSession.startTime ?? activeSession.date)
let sessionAttempts = attempts.filter { $0.sessionId == activeSession.id }
let totalAttempts = sessionAttempts.count
let completedProblems = Set(
sessionAttempts.filter { $0.result.isSuccessful }.map { $0.problemId }
).count
await LiveActivityManager.shared.updateLiveActivity(
elapsed: elapsed,
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
}
/// Update Live Activity with current session data /// Update Live Activity with current session data
private func updateLiveActivityForActiveSession() { private func updateLiveActivityForActiveSession() {
guard let activeSession = activeSession, guard let activeSession = activeSession,
@@ -1160,7 +1326,6 @@ extension ClimbingDataManager {
description: "Technical overhang with small holds", description: "Technical overhang with small holds",
climbType: .boulder, climbType: .boulder,
difficulty: DifficultyGrade(system: .vScale, grade: "V4"), difficulty: DifficultyGrade(system: .vScale, grade: "V4"),
setter: "John Doe",
tags: ["technical", "overhang"], tags: ["technical", "overhang"],
location: "Cave area" location: "Cave area"
) )

View File

@@ -1,12 +1,22 @@
import ActivityKit import ActivityKit
import Foundation import Foundation
extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
}
@MainActor @MainActor
final class LiveActivityManager { final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
private var currentActivity: Activity<SessionActivityAttributes>? private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date()
deinit {
healthCheckTimer?.invalidate()
}
/// Check if there's an active session and restart Live Activity if needed /// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
@@ -18,13 +28,31 @@ final class LiveActivityManager {
return return
} }
// Check if we already have a running Live Activity // Check if we have a tracked Live Activity that's still actually running
if currentActivity != nil { if let currentActivity = currentActivity {
print(" Live Activity already running") let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
print(" Live Activity still running: \(currentActivity.id)")
return
} else {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
)
self.currentActivity = nil
}
}
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
print(" Found existing Live Activity: \(existingActivity.id), using it")
self.currentActivity = existingActivity
return return
} }
print("🔄 Restarting Live Activity for existing session") print("🔄 No Live Activity found, restarting for existing session")
await startLiveActivity(for: activeSession, gymName: gymName) await startLiveActivity(for: activeSession, gymName: gymName)
} }
@@ -34,10 +62,17 @@ final class LiveActivityManager {
await endLiveActivity() await endLiveActivity()
// Start health checks once we have an active session
startHealthChecks()
// Calculate elapsed time if session already started
let startTime = session.startTime ?? session.date
let elapsed = Date().timeIntervalSince(startTime)
let attributes = SessionActivityAttributes( let attributes = SessionActivityAttributes(
gymName: gymName, startTime: session.startTime ?? session.date) gymName: gymName, startTime: startTime)
let initialContentState = SessionActivityAttributes.ContentState( let initialContentState = SessionActivityAttributes.ContentState(
elapsed: 0, elapsed: elapsed,
totalAttempts: 0, totalAttempts: 0,
completedProblems: 0 completedProblems: 0
) )
@@ -59,6 +94,8 @@ final class LiveActivityManager {
print("Authorization error - check Live Activity permissions in Settings") print("Authorization error - check Live Activity permissions in Settings")
} else if error.localizedDescription.contains("content") { } else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure") print("Content error - check ActivityAttributes structure")
} else if error.localizedDescription.contains("frequencyLimited") {
print("Frequency limited - too many Live Activities started recently")
} }
} }
} }
@@ -66,11 +103,23 @@ final class LiveActivityManager {
/// Call this to update the Live Activity with new session progress /// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{ {
guard let currentActivity else { guard let currentActivity = currentActivity else {
print("⚠️ No current activity to update") print("⚠️ No current activity to update")
return return
} }
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
)
self.currentActivity = nil
return
}
print( print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" "🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
) )
@@ -81,12 +130,21 @@ final class LiveActivityManager {
completedProblems: completedProblems completedProblems: completedProblems
) )
await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) do {
print("✅ Live Activity updated successfully") await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("✅ Live Activity updated successfully")
} catch {
print("❌ Failed to update Live Activity: \(error)")
// If update fails, the activity might have been dismissed
self.currentActivity = nil
}
} }
/// Call this when a ClimbSession ends to end the Live Activity /// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async { func endLiveActivity() async {
// Stop health checks first
stopHealthChecks()
// First end the tracked activity if it exists // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)") print("🔴 Ending tracked Live Activity: \(currentActivity.id)")
@@ -115,18 +173,92 @@ final class LiveActivityManager {
func checkLiveActivityAvailability() -> String { func checkLiveActivityAvailability() -> String {
let authorizationInfo = ActivityAuthorizationInfo() let authorizationInfo = ActivityAuthorizationInfo()
let status = authorizationInfo.areActivitiesEnabled let status = authorizationInfo.areActivitiesEnabled
let allActivities = Activity<SessionActivityAttributes>.activities
let message = """ let message = """
Live Activity Status: Live Activity Status:
• Enabled: \(status) • Enabled: \(status)
• Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown") • Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown")
Current Activity: \(currentActivity?.id.description ?? "None") Tracked Activity: \(currentActivity?.id.description ?? "None")
• All Active Activities: \(allActivities.count)
""" """
print(message) print(message)
return message return message
} }
/// Force check and cleanup dismissed Live Activities
func cleanupDismissedActivities() async {
let activities = Activity<SessionActivityAttributes>.activities
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("🧹 Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil
}
}
}
/// Start periodic health checks for Live Activity
func startHealthChecks() {
stopHealthChecks() // Stop any existing timer
print("🩺 Starting Live Activity health checks")
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor in
await self?.performHealthCheck()
}
}
}
/// Stop periodic health checks
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
print("🛑 Stopped Live Activity health checks")
}
/// Perform a health check on the current Live Activity
private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return }
let now = Date()
let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck)
// Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return }
print("🩺 Performing Live Activity health check")
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("💔 Health check failed - Live Activity was dismissed")
self.currentActivity = nil
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
} else {
print("✅ Live Activity health check passed")
}
}
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None"
let actualCount = activities.count
return "Status: \(trackedStatus) | Active Count: \(actualCount)"
}
/// Start periodic updates for Live Activity /// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{ {

View File

@@ -635,12 +635,6 @@ struct ProblemExpandedView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
if let setter = problem.setter, !setter.isEmpty {
Label(setter, systemImage: "person")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let description = problem.description, !description.isEmpty { if let description = problem.description, !description.isEmpty {
Text(description) Text(description)
.font(.body) .font(.body)

View File

@@ -13,7 +13,6 @@ struct AddEditProblemView: View {
@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 difficultyGrade = "" @State private var difficultyGrade = ""
@State private var setter = ""
@State private var location = "" @State private var location = ""
@State private var tags = "" @State private var tags = ""
@State private var notes = "" @State private var notes = ""
@@ -63,7 +62,7 @@ struct AddEditProblemView: View {
PhotosSection() PhotosSection()
ClimbTypeSection() ClimbTypeSection()
DifficultySection() DifficultySection()
LocationAndSetterSection() LocationSection()
TagsSection() TagsSection()
AdditionalInfoSection() AdditionalInfoSection()
} }
@@ -158,7 +157,6 @@ struct AddEditProblemView: View {
) )
} }
TextField("Route Setter (Optional)", text: $setter)
} }
} }
@@ -281,7 +279,7 @@ struct AddEditProblemView: View {
} }
@ViewBuilder @ViewBuilder
private func LocationAndSetterSection() -> some View { private func LocationSection() -> some View {
Section("Location & Details") { Section("Location & Details") {
TextField( TextField(
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'")) "Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
@@ -334,25 +332,28 @@ struct AddEditProblemView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) { if let uiImage = UIImage(data: imageData[index]) {
Image(uiImage: uiImage) ZStack(alignment: .topTrailing) {
.resizable() Image(uiImage: uiImage)
.aspectRatio(contentMode: .fill) .resizable()
.frame(width: 80, height: 80) .aspectRatio(contentMode: .fill)
.clipped() .frame(width: 80, height: 80)
.cornerRadius(8) .clipped()
.overlay(alignment: .topTrailing) { .cornerRadius(8)
Button(action: {
imageData.remove(at: index) Button(action: {
if index < imagePaths.count { imageData.remove(at: index)
imagePaths.remove(at: index) if index < imagePaths.count {
} imagePaths.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
} }
.offset(x: 8, y: -8) }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
.font(.system(size: 18))
} }
.offset(x: 4, y: -4)
}
.frame(width: 88, height: 88) // Extra space for button
} else { } else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3)) .fill(.gray.opacity(0.3))
@@ -365,6 +366,7 @@ struct AddEditProblemView: View {
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
.padding(.vertical, 8)
} }
} }
} }
@@ -410,7 +412,7 @@ struct AddEditProblemView: View {
selectedClimbType = problem.climbType selectedClimbType = problem.climbType
selectedDifficultySystem = problem.difficulty.system selectedDifficultySystem = problem.difficulty.system
difficultyGrade = problem.difficulty.grade difficultyGrade = problem.difficulty.grade
setter = problem.setter ?? ""
location = problem.location ?? "" location = problem.location ?? ""
tags = problem.tags.joined(separator: ", ") tags = problem.tags.joined(separator: ", ")
notes = problem.notes ?? "" notes = problem.notes ?? ""
@@ -420,7 +422,7 @@ struct AddEditProblemView: View {
// Load image data for preview // Load image data for preview
imageData = [] imageData = []
for imagePath in problem.imagePaths { for imagePath in problem.imagePaths {
if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) { if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
imageData.append(data) imageData.append(data)
} }
} }
@@ -479,7 +481,7 @@ struct AddEditProblemView: View {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTags = tags.split(separator: ",").map { let trimmedTags = tags.split(separator: ",").map {
@@ -494,7 +496,7 @@ struct AddEditProblemView: View {
description: trimmedDescription.isEmpty ? nil : trimmedDescription, description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty, difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: imagePaths,
@@ -510,7 +512,7 @@ struct AddEditProblemView: View {
description: trimmedDescription.isEmpty ? nil : trimmedDescription, description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType, climbType: selectedClimbType,
difficulty: difficulty, difficulty: difficulty,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: imagePaths,

View File

@@ -105,9 +105,26 @@ 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 @State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var gradeCountData: [GradeCount] { private var gradeCountData: [GradeCount] {
calculateGradeCounts() let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
} }
private var usedSystems: [DifficultySystem] { private var usedSystems: [DifficultySystem] {

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct GymDetailView: View { struct GymDetailView: View {
@@ -60,8 +59,10 @@ struct GymDetailView: View {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if gym != nil { if gym != nil {
Menu { Menu {
Button("Edit Gym") { Button {
// Navigate to edit view // Navigate to edit view
} label: {
Label("Edit Gym", systemImage: "pencil")
} }
Button(role: .destructive) { Button(role: .destructive) {

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct ProblemDetailView: View { struct ProblemDetailView: View {
@@ -64,8 +63,10 @@ struct ProblemDetailView: View {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
if problem != nil { if problem != nil {
Menu { Menu {
Button("Edit Problem") { Button {
showingEditProblem = true showingEditProblem = true
} label: {
Label("Edit Problem", systemImage: "pencil")
} }
Button(role: .destructive) { Button(role: .destructive) {
@@ -167,12 +168,6 @@ struct ProblemHeaderCard: View {
.font(.body) .font(.body)
} }
if let setter = problem.setter, !setter.isEmpty {
Text("Set by: \(setter)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if !problem.tags.isEmpty { if !problem.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {

View File

@@ -15,6 +15,18 @@ struct SessionDetailView: View {
dataManager.session(withId: sessionId) dataManager.session(withId: sessionId)
} }
private func startTimer() {
// Update every 5 seconds instead of 1 second for better performance
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private var gym: Gym? { private var gym: Gym? {
guard let session = session else { return nil } guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId) return dataManager.gym(withId: session.gymId)
@@ -35,7 +47,7 @@ struct SessionDetailView: View {
calculateSessionStats() calculateSessionStats()
} }
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var timer: Timer?
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -57,8 +69,11 @@ struct SessionDetailView: View {
} }
.padding() .padding()
} }
.onReceive(timer) { _ in .onAppear {
currentTime = Date() startTimer()
}
.onDisappear {
stopTimer()
} }
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -153,46 +168,14 @@ struct SessionDetailView: View {
let uniqueProblems = Set(attempts.map { $0.problemId }) let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId }) let completedProblems = Set(successfulAttempts.map { $0.problemId })
let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
let boulderRange = gradeRange(for: boulderProblems)
let ropeRange = gradeRange(for: ropeProblems)
return SessionStats( return SessionStats(
totalAttempts: attempts.count, totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count, successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count, uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count, uniqueProblemsCompleted: completedProblems.count
boulderRange: boulderRange,
ropeRange: ropeRange
) )
} }
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
let difficulties = problems.map { $0.difficulty }
// Group by difficulty system first
let groupedBySystem = Dictionary(grouping: difficulties) { $0.system }
// For each system, find the range
let ranges = groupedBySystem.compactMap { (system, difficulties) -> String? in
let sortedDifficulties = difficulties.sorted()
guard let min = sortedDifficulties.first, let max = sortedDifficulties.last else {
return nil
}
if min == max {
return min.grade
} else {
return "\(min.grade) - \(max.grade)"
}
}
return ranges.joined(separator: ", ")
}
} }
struct SessionHeaderCard: View { struct SessionHeaderCard: View {
@@ -297,22 +280,8 @@ struct SessionStatsCard: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)") StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)") StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)") StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
} }
// Grade ranges
VStack(alignment: .leading, spacing: 8) {
if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
HStack {
StatItem(label: "Boulder Range", value: boulderRange)
StatItem(label: "Rope Range", value: ropeRange)
}
} else if let singleRange = stats.boulderRange ?? stats.ropeRange {
StatItem(label: "Grade Range", value: singleRange)
.frame(maxWidth: .infinity, alignment: .center)
}
}
} }
} }
.padding() .padding()
@@ -504,8 +473,6 @@ struct SessionStats {
let successfulAttempts: Int let successfulAttempts: Int
let uniqueProblemsAttempted: Int let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int let uniqueProblemsCompleted: Int
let boulderRange: String?
let ropeRange: String?
} }
#Preview { #Preview {

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct GymsView: View { struct GymsView: View {
@@ -49,7 +48,10 @@ struct GymsList: View {
Button { Button {
gymToEdit = gym gymToEdit = gym
} label: { } label: {
Label("Edit", systemImage: "pencil") HStack {
Image(systemName: "pencil")
Text("Edit")
}
} }
.tint(.blue) .tint(.blue)
} }

View File

@@ -1,4 +1,3 @@
import SwiftUI import SwiftUI
struct ProblemsView: View { struct ProblemsView: View {
@@ -14,10 +13,9 @@ struct ProblemsView: View {
// Apply search filter // Apply search filter
if !searchText.isEmpty { if !searchText.isEmpty {
filtered = filtered.filter { problem in filtered = filtered.filter { problem in
(problem.name?.localizedCaseInsensitiveContains(searchText) ?? false) return problem.name?.localizedCaseInsensitiveContains(searchText) ?? false
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false) || (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false) || (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false)
|| problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) } || problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) }
} }
} }
@@ -32,7 +30,11 @@ struct ProblemsView: View {
filtered = filtered.filter { $0.gymId == gym.id } filtered = filtered.filter { $0.gymId == gym.id }
} }
return filtered.sorted { $0.updatedAt > $1.updatedAt } // Separate active and inactive problems
let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt }
return active + inactive
} }
var body: some View { var body: some View {
@@ -196,10 +198,23 @@ struct ProblemsList: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
Button {
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button { Button {
problemToEdit = problem problemToEdit = problem
} label: { } label: {
Label("Edit", systemImage: "pencil") HStack {
Image(systemName: "pencil")
Text("Edit")
}
} }
.tint(.blue) .tint(.blue)
} }
@@ -240,6 +255,7 @@ struct ProblemRow: View {
Text(problem.name ?? "Unnamed Problem") Text(problem.name ?? "Unnamed Problem")
.font(.headline) .font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(problem.isActive ? .primary : .secondary)
Text(gym?.name ?? "Unknown Gym") Text(gym?.name ?? "Unknown Gym")
.font(.subheadline) .font(.subheadline)
@@ -286,7 +302,7 @@ struct ProblemRow: View {
if !problem.imagePaths.isEmpty { if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { LazyHStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
ProblemImageView(imagePath: imagePath) ProblemImageView(imagePath: imagePath)
} }
@@ -296,9 +312,9 @@ struct ProblemRow: View {
} }
if !problem.isActive { if !problem.isActive {
Text("Inactive") Text("Reset / No Longer Set")
.font(.caption) .font(.caption)
.foregroundColor(.red) .foregroundColor(.orange)
.fontWeight(.medium) .fontWeight(.medium)
} }
} }
@@ -372,6 +388,13 @@ struct ProblemImageView: View {
@State private var isLoading = true @State private var isLoading = true
@State private var hasFailed = false @State private var hasFailed = false
private static var imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
return cache
}()
var body: some View { var body: some View {
Group { Group {
if let uiImage = uiImage { if let uiImage = uiImage {
@@ -412,10 +435,22 @@ struct ProblemImageView: View {
return return
} }
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath), if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data) let image = UIImage(data: data)
{ {
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
DispatchQueue.main.async { DispatchQueue.main.async {
self.uiImage = image self.uiImage = image
self.isLoading = false self.isLoading = false

View File

@@ -114,7 +114,7 @@ struct ActiveSessionBanner: View {
@State private var currentTime = Date() @State private var currentTime = Date()
@State private var navigateToDetail = false @State private var navigateToDetail = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @State private var timer: Timer?
var body: some View { var body: some View {
HStack { HStack {
@@ -162,8 +162,11 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1)) .fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1) .stroke(.green.opacity(0.3), lineWidth: 1)
) )
.onReceive(timer) { _ in .onAppear {
currentTime = Date() startTimer()
}
.onDisappear {
stopTimer()
} }
.background( .background(
NavigationLink( NavigationLink(
@@ -190,6 +193,17 @@ struct ActiveSessionBanner: View {
return String(format: "%ds", seconds) return String(format: "%ds", seconds)
} }
} }
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
currentTime = Date()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
} }
struct SessionRow: View { struct SessionRow: View {

View File

@@ -164,60 +164,70 @@ struct ExportDataView: View {
let data: Data let data: Data
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL? @State private var tempFileURL: URL?
@State private var isCreatingFile = true
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 20) { VStack(spacing: 30) {
Image(systemName: "square.and.arrow.up") if isCreatingFile {
.font(.system(size: 60)) // Loading state - more prominent
.foregroundColor(.blue) VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
Text("Export Data") Text("Preparing Your Export")
.font(.title) .font(.title2)
.fontWeight(.bold) .fontWeight(.semibold)
Text( Text("Creating ZIP file with your climbing data and images...")
"Your climbing data has been prepared for export. Use the share button below to save or send your data." .font(.body)
) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image("MountainsIcon"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
} }
.padding(.horizontal) .frame(maxWidth: .infinity, maxHeight: .infinity)
.buttonStyle(.plain)
} else { } else {
Button(action: {}) { // Ready state
Label("Preparing Export...", systemImage: "hourglass") VStack(spacing: 20) {
.font(.headline) Image(systemName: "checkmark.circle.fill")
.foregroundColor(.white) .font(.system(size: 60))
.frame(maxWidth: .infinity) .foregroundColor(.green)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
}
.disabled(true)
.padding(.horizontal)
}
Spacer() Text("Export Ready!")
.font(.title)
.fontWeight(.bold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image("MountainsIcon"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
}
.padding(.horizontal)
.buttonStyle(.plain)
}
}
Spacer()
}
} }
.padding() .padding()
.navigationTitle("Export") .navigationTitle("Export")
@@ -259,6 +269,9 @@ struct ExportDataView: View {
).first ).first
else { else {
print("Could not access Documents directory") print("Could not access Documents directory")
DispatchQueue.main.async {
self.isCreatingFile = false
}
return return
} }
let fileURL = documentsURL.appendingPathComponent(filename) let fileURL = documentsURL.appendingPathComponent(filename)
@@ -268,9 +281,13 @@ struct ExportDataView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
self.tempFileURL = fileURL self.tempFileURL = fileURL
self.isCreatingFile = false
} }
} catch { } catch {
print("Failed to create export file: \(error)") print("Failed to create export file: \(error)")
DispatchQueue.main.async {
self.isCreatingFile = false
}
} }
} }
} }