Compare commits

...

1 Commits

Author SHA1 Message Date
d263c6c87e iOS and Android dependency updates and optimizations 2026-01-06 12:27:28 -07:00
23 changed files with 297 additions and 254 deletions

View File

@@ -18,8 +18,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 49 versionCode = 50
versionName = "2.4.1" versionName = "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -10,6 +10,7 @@ import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.units.Energy import androidx.health.connect.client.units.Energy
import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.SessionStatus import com.atridad.ascently.data.model.SessionStatus
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.flow
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import androidx.core.content.edit
/** /**
* Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit, * Health Connect manager for Ascently that syncs climbing sessions to Samsung Health, Google Fit,
@@ -197,6 +199,7 @@ class HealthConnectManager(private val context: Context) {
exerciseType = exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName", title = "Rock Climbing at $gymName",
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
records.add(exerciseSession) records.add(exerciseSession)
} catch (e: Exception) { } catch (e: Exception) {
@@ -217,6 +220,7 @@ class HealthConnectManager(private val context: Context) {
endZoneOffset = endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime), ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories), energy = Energy.calories(estimatedCalories),
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
records.add(caloriesRecord) records.add(caloriesRecord)
} }
@@ -239,9 +243,9 @@ class HealthConnectManager(private val context: Context) {
} }
preferences preferences
.edit() .edit {
.putString("last_sync_success", DateFormatUtils.nowISO8601()) putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply() }
} else { } else {
val reason = val reason =
when { when {
@@ -326,6 +330,7 @@ class HealthConnectManager(private val context: Context) {
endTime = endTime, endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples, samples = samples,
metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(),
) )
} catch (e: Exception) { } catch (e: Exception) {
AppLogger.e(TAG, e) { "Error creating heart rate record" } AppLogger.e(TAG, e) { "Error creating heart rate record" }

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -34,6 +35,7 @@ enum class AttemptResult {
], ],
indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])], indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])],
) )
@Immutable
@Serializable @Serializable
data class Attempt( data class Attempt(
@PrimaryKey val id: String, @PrimaryKey val id: String,

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -27,6 +28,7 @@ enum class SessionStatus {
], ],
indices = [Index(value = ["gymId"])], indices = [Index(value = ["gymId"])],
) )
@Immutable
@Serializable @Serializable
data class ClimbSession( data class ClimbSession(
@PrimaryKey val id: String, @PrimaryKey val id: String,

View File

@@ -1,10 +1,12 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Immutable
@Entity(tableName = "gyms") @Entity(tableName = "gyms")
@Serializable @Serializable
data class Gym( data class Gym(

View File

@@ -1,5 +1,6 @@
package com.atridad.ascently.data.model package com.atridad.ascently.data.model
import androidx.compose.runtime.Immutable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
@@ -7,6 +8,7 @@ import androidx.room.PrimaryKey
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Immutable
@Entity( @Entity(
tableName = "problems", tableName = "problems",
foreignKeys = foreignKeys =

View File

@@ -15,6 +15,7 @@ import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -237,9 +238,8 @@ class SessionTrackingService : Service() {
val startTimeMillis = val startTimeMillis =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
val start = LocalDateTime.parse(startTime) DateFormatUtils.parseISO8601(startTime)?.toEpochMilli()
val zoneId = ZoneId.systemDefault() ?: System.currentTimeMillis()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
@@ -263,9 +263,9 @@ class SessionTrackingService : Service() {
val duration = val duration =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
val start = LocalDateTime.parse(startTime) val start = DateFormatUtils.parseISO8601(startTime)
val now = LocalDateTime.now() val now = java.time.Instant.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now) val totalSeconds = if (start != null) ChronoUnit.SECONDS.between(start, now) else 0L
val hours = totalSeconds / 3600 val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60 val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60

View File

@@ -13,8 +13,9 @@ import androidx.compose.ui.unit.dp
import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.ClimbSession
import com.atridad.ascently.data.model.Gym import com.atridad.ascently.data.model.Gym
import com.atridad.ascently.ui.theme.CustomIcons import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.utils.DateFormatUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.time.LocalDateTime import java.time.Instant
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@Composable @Composable
@@ -23,101 +24,112 @@ fun ActiveSessionBanner(
gym: Gym?, gym: Gym?,
onSessionClick: () -> Unit, onSessionClick: () -> Unit,
onEndSession: () -> Unit, onEndSession: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
if (activeSession != null) { if (activeSession == null) return
// Add a timer that updates every second for real-time duration counting
var currentTime by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) { val sessionId = activeSession.id
while (true) { val startTimeString = activeSession.startTime
delay(1000) // Update every second val gymName = gym?.name ?: "Unknown Gym"
currentTime = LocalDateTime.now()
} var elapsedSeconds by remember(sessionId) { mutableLongStateOf(0L) }
LaunchedEffect(sessionId, startTimeString) {
if (startTimeString == null) return@LaunchedEffect
while (true) {
elapsedSeconds = calculateElapsedSeconds(startTimeString)
delay(1000)
} }
}
Card( val durationText = remember(elapsedSeconds) {
formatDuration(elapsedSeconds)
}
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onSessionClick() },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onSessionClick() }, .padding(16.dp),
colors = CardDefaults.cardColors( horizontalArrangement = Arrangement.SpaceBetween,
containerColor = MaterialTheme.colorScheme.primaryContainer, verticalAlignment = Alignment.CenterVertically,
),
) { ) {
Row( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier Row(verticalAlignment = Alignment.CenterVertically) {
.fillMaxWidth() Icon(
.padding(16.dp), Icons.Default.PlayArrow,
horizontalArrangement = Arrangement.SpaceBetween, contentDescription = null,
verticalAlignment = Alignment.CenterVertically, tint = MaterialTheme.colorScheme.primary,
) { modifier = Modifier.size(16.dp),
Column(modifier = Modifier.weight(1f)) { )
Row( Spacer(modifier = Modifier.width(8.dp))
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = "Active session",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Active Session",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = gym?.name ?: "Unknown Gym", text = "Active Session",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
activeSession.startTime?.let { startTime ->
val duration = calculateDuration(startTime, currentTime)
Text(
text = duration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
)
}
} }
IconButton( Spacer(modifier = Modifier.height(4.dp))
onClick = onEndSession,
colors = IconButtonDefaults.iconButtonColors( Text(
containerColor = MaterialTheme.colorScheme.error, text = gymName,
contentColor = MaterialTheme.colorScheme.onError, style = MaterialTheme.typography.bodyMedium,
), color = MaterialTheme.colorScheme.onPrimaryContainer,
) { )
Icon(
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError), if (startTimeString != null) {
contentDescription = "End session", Text(
text = durationText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
) )
} }
} }
FilledIconButton(
onClick = onEndSession,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Icon(
imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError),
contentDescription = "End session",
)
}
} }
} }
} }
private fun calculateDuration(startTimeString: String, currentTime: LocalDateTime): String { private fun calculateElapsedSeconds(startTimeString: String): Long {
return try { return try {
val startTime = LocalDateTime.parse(startTimeString) val startTime = DateFormatUtils.parseISO8601(startTimeString) ?: return 0L
val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime) val now = Instant.now()
val hours = totalSeconds / 3600 ChronoUnit.SECONDS.between(startTime, now).coerceAtLeast(0)
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) { } catch (_: Exception) {
"Active" 0L
}
}
private fun formatDuration(totalSeconds: Long): String {
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${seconds}s"
} }
} }

View File

@@ -27,6 +27,16 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
val attempts by viewModel.attempts.collectAsState() val attempts by viewModel.attempts.collectAsState()
val gyms by viewModel.gyms.collectAsState() val gyms by viewModel.gyms.collectAsState()
val gradeDistributionData = remember(sessions, problems, attempts) {
calculateGradeDistribution(sessions, problems, attempts)
}
val favoriteGym = remember(sessions, gyms) {
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { (gymId, sessionList) ->
gyms.find { it.id == gymId }?.name to sessionList.size
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -65,18 +75,11 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) {
// Grade Distribution Chart // Grade Distribution Chart
item { item {
val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts)
GradeDistributionChartCard(gradeDistributionData = gradeDistributionData) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData)
} }
// Favorite Gym // Favorite Gym
item { item {
val favoriteGym =
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, sessions) ->
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,

View File

@@ -225,26 +225,24 @@ fun SessionDetailScreen(
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) } var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(null) }
// Get session details // Get session details
val session = sessions.find { it.id == sessionId } val session = remember(sessions, sessionId) { sessions.find { it.id == sessionId } }
val gym = session?.let { s -> gyms.find { it.id == s.gymId } } val gym = remember(session, gyms) { session?.let { s -> gyms.find { it.id == s.gymId } } }
// Calculate stats // Calculate stats
val successfulAttempts = val successfulAttempts = remember(attempts) {
attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) } attempts.filter { it.result in listOf(AttemptResult.SUCCESS, AttemptResult.FLASH) }
val uniqueProblems = attempts.map { it.problemId }.distinct() }
val uniqueProblems = remember(attempts) { attempts.map { it.problemId }.distinct() }
val completedProblems = remember(successfulAttempts) { successfulAttempts.map { it.problemId }.distinct() }
val completedProblems = successfulAttempts.map { it.problemId }.distinct() val attemptsWithProblems = remember(attempts, problems) {
val attemptsWithProblems =
attempts attempts
.mapNotNull { attempt -> .mapNotNull { attempt ->
val problem = problems.find { it.id == attempt.problemId } val problem = problems.find { it.id == attempt.problemId }
if (problem != null) attempt to problem else null if (problem != null) attempt to problem else null
} }
.sortedBy { attempt -> .sortedBy { it.first.timestamp }
// Sort by timestamp (when attempt was logged) }
attempt.first.timestamp
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -267,7 +265,7 @@ fun SessionDetailScreen(
if (session?.status == SessionStatus.ACTIVE) { if (session?.status == SessionStatus.ACTIVE) {
IconButton( IconButton(
onClick = { onClick = {
session.let { s -> session?.let { s ->
viewModel.endSession(context, s.id) viewModel.endSession(context, s.id)
onNavigateBack() onNavigateBack()
} }
@@ -313,8 +311,11 @@ fun SessionDetailScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val formattedDate = remember(session?.date) {
DateFormatUtils.formatDateForDisplay(session?.date ?: "")
}
Text( Text(
text = formatDate(session?.date ?: ""), text = formattedDate,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -503,11 +504,13 @@ fun SessionDetailScreen(
) )
} }
if (showAddAttemptDialog && session != null && gym != null) { val currentSession = session
val currentGym = gym
if (showAddAttemptDialog && currentSession != null && currentGym != null) {
EnhancedAddAttemptDialog( EnhancedAddAttemptDialog(
session = session, session = currentSession,
gym = gym, gym = currentGym,
problems = problems.filter { it.gymId == gym.id && it.isActive }, problems = problems.filter { it.gymId == currentGym.id && it.isActive },
onDismiss = { showAddAttemptDialog = false }, onDismiss = { showAddAttemptDialog = false },
onAttemptAdded = { attempt -> onAttemptAdded = { attempt ->
viewModel.addAttempt(attempt) viewModel.addAttempt(attempt)
@@ -728,9 +731,12 @@ fun ProblemDetailScreen(
} }
firstSuccess?.let { attempt -> firstSuccess?.let { attempt ->
val session = sessions.find { it.id == attempt.sessionId } val session = sessions.find { it.id == attempt.sessionId }
val firstSuccessDate = remember(session?.date) {
DateFormatUtils.formatDateForDisplay(session?.date ?: "")
}
Text( Text(
text = text =
"First success: ${formatDate(session?.date ?: "")} (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})", "First success: $firstSuccessDate (${attempt.result.name.lowercase().replaceFirstChar { it.uppercase() }})",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -1309,6 +1315,10 @@ fun StatItem(label: String, value: String) {
@Composable @Composable
fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) {
val formattedDate = remember(session.date) {
DateFormatUtils.formatDateForDisplay(session.date)
}
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
@@ -1318,7 +1328,7 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) {
) { ) {
Column { Column {
Text( Text(
text = formatDate(session.date), text = formattedDate,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@@ -1478,9 +1488,7 @@ fun SessionAttemptCard(
} }
} }
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -52,7 +52,7 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni
) )
} else { } else {
LazyColumn { LazyColumn {
items(gyms) { gym -> items(gyms, key = { it.id }) { gym ->
GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) }) GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }

View File

@@ -35,17 +35,18 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
var selectedGym by remember { mutableStateOf<Gym?>(null) } var selectedGym by remember { mutableStateOf<Gym?>(null) }
// Apply filters // Apply filters
val filteredProblems = val filteredProblems = remember(problems, selectedClimbType, selectedGym) {
problems.filter { problem -> problems.filter { problem ->
val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false
val gymMatch = selectedGym?.let { it.id == problem.gymId } != false val gymMatch = selectedGym?.let { it.id == problem.gymId } != false
climbTypeMatch && gymMatch climbTypeMatch && gymMatch
} }
}
// Separate active and inactive problems // Separate active and inactive problems
val activeProblems = filteredProblems.filter { it.isActive } val activeProblems = remember(filteredProblems) { filteredProblems.filter { it.isActive } }
val inactiveProblems = filteredProblems.filter { !it.isActive } val inactiveProblems = remember(filteredProblems) { filteredProblems.filter { !it.isActive } }
val sortedProblems = activeProblems + inactiveProblems val sortedProblems = remember(activeProblems, inactiveProblems) { activeProblems + inactiveProblems }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -175,7 +176,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
) )
} else { } else {
LazyColumn { LazyColumn {
items(sortedProblems) { problem -> items(sortedProblems, key = { it.id }) { 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",

View File

@@ -61,8 +61,12 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
var selectedMonth by remember { mutableStateOf(YearMonth.now()) } var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) } var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = remember(sessions) {
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } sessions.filter { it.status == SessionStatus.COMPLETED }
}
val activeSessionGym = remember(activeSession, gyms) {
activeSession?.let { session -> gyms.find { it.id == session.gymId } }
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -136,7 +140,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
when (viewMode) { when (viewMode) {
ViewMode.LIST -> { ViewMode.LIST -> {
LazyColumn { LazyColumn {
items(completedSessions) { session -> items(completedSessions, key = { it.id }) { session ->
SessionCard( SessionCard(
session = session, session = session,
gymName = gyms.find { it.id == session.gymId }?.name gymName = gyms.find { it.id == session.gymId }?.name
@@ -232,6 +236,10 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) { fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
val formattedDate = remember(session.date) {
DateFormatUtils.formatDateForDisplay(session.date)
}
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row( Row(
@@ -244,7 +252,7 @@ fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = formatDate(session.date), text = formattedDate,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -539,7 +547,3 @@ fun CalendarDay(
} }
} }
} }
private fun formatDate(dateString: String): String {
return DateFormatUtils.formatDateForDisplay(dateString)
}

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.12.3" agp = "8.12.3"
kotlin = "2.2.21" kotlin = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -10,8 +10,8 @@ androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0" activityCompose = "1.12.2"
composeBom = "2025.11.01" composeBom = "2025.12.01"
room = "2.8.4" room = "2.8.4"
navigation = "2.9.6" navigation = "2.9.6"
viewmodel = "2.10.0" viewmodel = "2.10.0"
@@ -19,10 +19,10 @@ kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" coil = "2.7.0"
ksp = "2.2.20-2.0.3" ksp = "2.2.20-2.0.3"
exifinterface = "1.4.1" exifinterface = "1.4.2"
healthConnect = "1.1.0-alpha07" healthConnect = "1.1.0"
detekt = "1.23.7" detekt = "1.23.8"
spotless = "6.25.0" spotless = "8.1.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39; CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39; CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,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 = 39; CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -632,7 +632,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 = 39; CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.5.2; MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -259,7 +259,7 @@ struct ProgressChartSection: View {
.font(.title) .font(.title)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No data available for \(selectedSystem.displayName) system") Text("No data available.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }

View File

@@ -57,34 +57,29 @@ struct GymDetailView: View {
.navigationTitle(gym?.name ?? "Gym Details") .navigationTitle(gym?.name ?? "Gym Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if gym != nil { if gym != nil {
Menu { Button {
Button { showingDeleteAlert = true
// Navigate to edit view
} label: {
Label("Edit Gym", systemImage: "pencil")
}
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Gym", systemImage: "trash")
}
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "trash")
} }
.tint(.red)
} }
} }
} }
.alert("Delete Gym", isPresented: $showingDeleteAlert) { .confirmationDialog(
Button("Cancel", role: .cancel) {} "Delete Gym",
isPresented: $showingDeleteAlert,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let gym = gym { if let gym = gym {
dataManager.deleteGym(gym) dataManager.deleteGym(gym)
dismiss() dismiss()
} }
} }
Button("Cancel", role: .cancel) {}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym." "Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym."
@@ -131,7 +126,6 @@ struct GymHeaderCard: View {
} }
} }
// Supported Climb Types
if !gym.supportedClimbTypes.isEmpty { if !gym.supportedClimbTypes.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Climb Types") Text("Climb Types")
@@ -157,7 +151,6 @@ struct GymHeaderCard: View {
} }
} }
// Difficulty Systems
if !gym.difficultySystems.isEmpty { if !gym.difficultySystems.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Difficulty Systems") Text("Difficulty Systems")
@@ -330,6 +323,12 @@ struct SessionRowCard: View {
let session: ClimbSession let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
private var sessionAttempts: [Attempt] { private var sessionAttempts: [Attempt] {
dataManager.attempts(forSession: session.id) dataManager.attempts(forSession: session.id)
} }
@@ -357,7 +356,7 @@ struct SessionRowCard: View {
} }
} }
Text("\(formatDate(session.date))\(sessionAttempts.count) attempts") Text("\(Self.dateFormatter.string(from: session.date))\(sessionAttempts.count) attempts")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -377,12 +376,6 @@ struct SessionRowCard: View {
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
} }
struct EmptyGymStateView: View { struct EmptyGymStateView: View {

View File

@@ -61,7 +61,7 @@ struct ProblemDetailView: View {
.navigationTitle("Problem Details") .navigationTitle("Problem Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if problem != nil { if problem != nil {
Menu { Menu {
Button { Button {
@@ -81,14 +81,18 @@ struct ProblemDetailView: View {
} }
} }
} }
.alert("Delete Problem", isPresented: $showingDeleteAlert) { .confirmationDialog(
Button("Cancel", role: .cancel) {} "Delete Problem",
isPresented: $showingDeleteAlert,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let problem = problem { if let problem = problem {
dataManager.deleteProblem(problem) dataManager.deleteProblem(problem)
dismiss() dismiss()
} }
} }
Button("Cancel", role: .cancel) {}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this problem? This will also delete all attempts associated with this problem." "Are you sure you want to delete this problem? This will also delete all attempts associated with this problem."
@@ -227,6 +231,12 @@ struct ProgressSummaryCard: View {
let firstSuccess: (date: Date, result: AttemptResult)? let firstSuccess: (date: Date, result: AttemptResult)?
@EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var themeManager: ThemeManager
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Progress Summary") Text("Progress Summary")
@@ -251,7 +261,7 @@ struct ProgressSummaryCard: View {
.fontWeight(.medium) .fontWeight(.medium)
Text( Text(
"\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))" "\(Self.dateFormatter.string(from: firstSuccess.date)) (\(firstSuccess.result.displayName))"
) )
.font(.subheadline) .font(.subheadline)
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
@@ -266,12 +276,6 @@ struct ProgressSummaryCard: View {
.fill(.regularMaterial) .fill(.regularMaterial)
) )
} }
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
} }
struct PhotosSection: View { struct PhotosSection: View {
@@ -360,6 +364,12 @@ struct AttemptHistoryCard: View {
let session: ClimbSession let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
private var gym: Gym? { private var gym: Gym? {
dataManager.gym(withId: session.gymId) dataManager.gym(withId: session.gymId)
} }
@@ -368,7 +378,7 @@ struct AttemptHistoryCard: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(formatDate(session.date)) Text(Self.dateFormatter.string(from: session.date))
.font(.headline) .font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -403,12 +413,6 @@ struct AttemptHistoryCard: View {
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
} }
struct ImageViewerView: View { struct ImageViewerView: View {

View File

@@ -134,7 +134,7 @@ struct SessionDetailView: View {
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if let session = session { if let session = session {
if session.status == .active { if session.status == .active {
Button("End Session") { Button("End Session") {
@@ -143,15 +143,12 @@ struct SessionDetailView: View {
} }
.foregroundColor(.orange) .foregroundColor(.orange)
} else { } else {
Menu { Button {
Button(role: .destructive) { showingDeleteAlert = true
showingDeleteAlert = true
} label: {
Label("Delete Session", systemImage: "trash")
}
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "trash")
} }
.tint(.red)
} }
} }
} }
@@ -188,7 +185,7 @@ struct SessionDetailView: View {
Button(action: { showingAddAttempt = true }) { Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.title2) .font(.title2)
.foregroundColor(.white) // Keep white for contrast on colored button .foregroundColor(.white)
.frame(width: 56, height: 56) .frame(width: 56, height: 56)
.background(Circle().fill(themeManager.accentColor)) .background(Circle().fill(themeManager.accentColor))
.shadow(radius: 4) .shadow(radius: 4)
@@ -196,14 +193,18 @@ struct SessionDetailView: View {
.padding() .padding()
} }
} }
.alert("Delete Session", isPresented: $showingDeleteAlert) { .confirmationDialog(
Button("Cancel", role: .cancel) {} "Delete Session",
isPresented: $showingDeleteAlert,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let session = session { if let session = session {
dataManager.deleteSession(session) dataManager.deleteSession(session)
dismiss() dismiss()
} }
} }
Button("Cancel", role: .cancel) {}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session." "Are you sure you want to delete this session? This will also delete all attempts associated with this session."
@@ -239,6 +240,12 @@ struct SessionHeaderCard: View {
let stats: SessionStats let stats: SessionStats
@EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var themeManager: ThemeManager
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter
}()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -246,7 +253,7 @@ struct SessionHeaderCard: View {
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
Text(formatDate(session.date)) Text(Self.dateFormatter.string(from: session.date))
.font(.title2) .font(.title2)
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
@@ -273,7 +280,6 @@ struct SessionHeaderCard: View {
} }
} }
// Status indicator
HStack { HStack {
Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill") Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill")
.foregroundColor(session.status == .active ? .green : themeManager.accentColor) .foregroundColor(session.status == .active ? .green : themeManager.accentColor)
@@ -298,12 +304,6 @@ struct SessionHeaderCard: View {
.fill(.regularMaterial) .fill(.regularMaterial)
) )
} }
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: date)
}
} }
struct SessionStatsCard: View { struct SessionStatsCard: View {
@@ -356,8 +356,6 @@ struct StatItem: View {
} }
} }
// AttemptsSection removed as it is now integrated into the main List
struct AttemptCard: View { struct AttemptCard: View {
let attempt: Attempt let attempt: Attempt
let problem: Problem let problem: Problem
@@ -404,7 +402,7 @@ struct AttemptCard: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color(uiColor: .secondarySystemGroupedBackground)) // Better contrast in light mode .fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }

View File

@@ -77,16 +77,23 @@ struct GymsList: View {
.tint(.indigo) .tint(.indigo)
} }
} }
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { .confirmationDialog(
Button("Cancel", role: .cancel) { "Delete Gym",
gymToDelete = nil isPresented: .init(
} get: { gymToDelete != nil },
set: { if !$0 { gymToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let gym = gymToDelete { if let gym = gymToDelete {
dataManager.deleteGym(gym) dataManager.deleteGym(gym)
gymToDelete = nil gymToDelete = nil
} }
} }
Button("Cancel", role: .cancel) {
gymToDelete = nil
}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this gym? This will also delete all associated problems and sessions." "Are you sure you want to delete this gym? This will also delete all associated problems and sessions."

View File

@@ -209,16 +209,23 @@ struct ProblemsView: View {
.sheet(item: $problemToEdit) { problem in .sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id) AddEditProblemView(problemId: problem.id)
} }
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { .confirmationDialog(
Button("Cancel", role: .cancel) { "Delete Problem",
problemToDelete = nil isPresented: .init(
} get: { problemToDelete != nil },
set: { if !$0 { problemToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let problem = problemToDelete { if let problem = problemToDelete {
dataManager.deleteProblem(problem) dataManager.deleteProblem(problem)
problemToDelete = nil problemToDelete = nil
} }
} }
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts." "Are you sure you want to delete this problem? This will also delete all associated attempts."

View File

@@ -62,7 +62,6 @@ struct SessionsView: View {
) )
} }
// View mode toggle
if !dataManager.sessions.isEmpty || dataManager.activeSession != nil { if !dataManager.sessions.isEmpty || dataManager.activeSession != nil {
Button(action: { Button(action: {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
@@ -116,7 +115,6 @@ struct SessionsList: View {
var body: some View { var body: some View {
List { List {
// Active session banner section
if let activeSession = dataManager.activeSession, if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId) let gym = dataManager.gym(withId: activeSession.gymId)
{ {
@@ -130,7 +128,6 @@ struct SessionsList: View {
} }
} }
// Completed sessions section
if !completedSessions.isEmpty { if !completedSessions.isEmpty {
Section { Section {
ForEach(completedSessions) { session in ForEach(completedSessions) { session in
@@ -156,16 +153,23 @@ struct SessionsList: View {
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { .confirmationDialog(
Button("Cancel", role: .cancel) { "Delete Session",
sessionToDelete = nil isPresented: .init(
} get: { sessionToDelete != nil },
set: { if !$0 { sessionToDelete = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
if let session = sessionToDelete { if let session = sessionToDelete {
dataManager.deleteSession(session) dataManager.deleteSession(session)
sessionToDelete = nil sessionToDelete = nil
} }
} }
Button("Cancel", role: .cancel) {
sessionToDelete = nil
}
} message: { } message: {
Text( Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session." "Are you sure you want to delete this session? This will also delete all attempts associated with this session."
@@ -178,18 +182,8 @@ struct ActiveSessionBanner: View {
let session: ClimbSession let session: ClimbSession
let gym: Gym let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@State private var navigateToDetail = false
// Access MusicService via DataManager if possible, or EnvironmentObject if injected
// Since DataManager holds MusicService, we can access it through there if we expose it or inject it.
// In SettingsView we saw .environmentObject(dataManager.musicService).
// We should probably inject it here too or access via dataManager if it's public.
// Let's check ClimbingDataManager again. It has `let musicService = MusicService.shared`.
// But it's not @Published so it won't trigger updates unless we observe the service itself.
// The best way is to use @EnvironmentObject var musicService: MusicService
// and ensure it's injected in the parent view.
@EnvironmentObject var musicService: MusicService @EnvironmentObject var musicService: MusicService
@State private var navigateToDetail = false
var body: some View { var body: some View {
HStack { HStack {
@@ -251,7 +245,6 @@ 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)
) )
.navigationDestination(isPresented: $navigateToDetail) { .navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id) SessionDetailView(sessionId: session.id)
} }
@@ -262,6 +255,12 @@ struct SessionRow: View {
let session: ClimbSession let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
private var gym: Gym? { private var gym: Gym? {
dataManager.gym(withId: session.gymId) dataManager.gym(withId: session.gymId)
} }
@@ -275,7 +274,7 @@ struct SessionRow: View {
Spacer() Spacer()
Text(formatDate(session.date)) Text(Self.dateFormatter.string(from: session.date))
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -295,12 +294,6 @@ struct SessionRow: View {
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
} }
struct EmptySessionsView: View { struct EmptySessionsView: View {