diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a8109de..be65167 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 49 - versionName = "2.4.1" + versionCode = 50 + versionName = "2.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt index cc88aec..0ad1cf2 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/health/HealthConnectStub.kt @@ -10,6 +10,7 @@ import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.ExerciseSessionRecord import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.TotalCaloriesBurnedRecord + import androidx.health.connect.client.units.Energy import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.SessionStatus @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.flow import java.time.Duration import java.time.Instant import java.time.ZoneOffset +import androidx.core.content.edit /** * 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 = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, title = "Rock Climbing at $gymName", + metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(), ) records.add(exerciseSession) } catch (e: Exception) { @@ -217,6 +220,7 @@ class HealthConnectManager(private val context: Context) { endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), energy = Energy.calories(estimatedCalories), + metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(), ) records.add(caloriesRecord) } @@ -239,9 +243,9 @@ class HealthConnectManager(private val context: Context) { } preferences - .edit() - .putString("last_sync_success", DateFormatUtils.nowISO8601()) - .apply() + .edit { + putString("last_sync_success", DateFormatUtils.nowISO8601()) + } } else { val reason = when { @@ -326,6 +330,7 @@ class HealthConnectManager(private val context: Context) { endTime = endTime, endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime), samples = samples, + metadata = androidx.health.connect.client.records.metadata.Metadata.manualEntry(), ) } catch (e: Exception) { AppLogger.e(TAG, e) { "Error creating heart rate record" } diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt index e00de87..5f80023 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/Attempt.kt @@ -1,5 +1,6 @@ package com.atridad.ascently.data.model +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -34,6 +35,7 @@ enum class AttemptResult { ], indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])], ) +@Immutable @Serializable data class Attempt( @PrimaryKey val id: String, diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt index cb830b4..c7f7132 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/ClimbSession.kt @@ -1,5 +1,6 @@ package com.atridad.ascently.data.model +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -27,6 +28,7 @@ enum class SessionStatus { ], indices = [Index(value = ["gymId"])], ) +@Immutable @Serializable data class ClimbSession( @PrimaryKey val id: String, diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/Gym.kt b/android/app/src/main/java/com/atridad/ascently/data/model/Gym.kt index 7cea90f..df24b46 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/Gym.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/Gym.kt @@ -1,10 +1,12 @@ package com.atridad.ascently.data.model +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import com.atridad.ascently.utils.DateFormatUtils import kotlinx.serialization.Serializable +@Immutable @Entity(tableName = "gyms") @Serializable data class Gym( diff --git a/android/app/src/main/java/com/atridad/ascently/data/model/Problem.kt b/android/app/src/main/java/com/atridad/ascently/data/model/Problem.kt index 199ce44..8689c7b 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/model/Problem.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/model/Problem.kt @@ -1,5 +1,6 @@ package com.atridad.ascently.data.model +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -7,6 +8,7 @@ import androidx.room.PrimaryKey import com.atridad.ascently.utils.DateFormatUtils import kotlinx.serialization.Serializable +@Immutable @Entity( tableName = "problems", foreignKeys = diff --git a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt index 74fbc21..9a095a1 100644 --- a/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt +++ b/android/app/src/main/java/com/atridad/ascently/service/SessionTrackingService.kt @@ -15,6 +15,7 @@ import com.atridad.ascently.R import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.utils.AppLogger +import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.widget.ClimbStatsWidgetProvider import kotlinx.coroutines.* import kotlinx.coroutines.flow.firstOrNull @@ -237,9 +238,8 @@ class SessionTrackingService : Service() { val startTimeMillis = session.startTime?.let { startTime -> try { - val start = LocalDateTime.parse(startTime) - val zoneId = ZoneId.systemDefault() - start.atZone(zoneId).toInstant().toEpochMilli() + DateFormatUtils.parseISO8601(startTime)?.toEpochMilli() + ?: System.currentTimeMillis() } catch (_: Exception) { System.currentTimeMillis() } @@ -263,9 +263,9 @@ class SessionTrackingService : Service() { val duration = session.startTime?.let { startTime -> try { - val start = LocalDateTime.parse(startTime) - val now = LocalDateTime.now() - val totalSeconds = ChronoUnit.SECONDS.between(start, now) + val start = DateFormatUtils.parseISO8601(startTime) + val now = java.time.Instant.now() + val totalSeconds = if (start != null) ChronoUnit.SECONDS.between(start, now) else 0L val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 val seconds = totalSeconds % 60 diff --git a/android/app/src/main/java/com/atridad/ascently/ui/components/ActiveSessionBanner.kt b/android/app/src/main/java/com/atridad/ascently/ui/components/ActiveSessionBanner.kt index 4d6f456..c1d29a4 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/components/ActiveSessionBanner.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/components/ActiveSessionBanner.kt @@ -13,8 +13,9 @@ import androidx.compose.ui.unit.dp import com.atridad.ascently.data.model.ClimbSession import com.atridad.ascently.data.model.Gym import com.atridad.ascently.ui.theme.CustomIcons +import com.atridad.ascently.utils.DateFormatUtils import kotlinx.coroutines.delay -import java.time.LocalDateTime +import java.time.Instant import java.time.temporal.ChronoUnit @Composable @@ -23,101 +24,112 @@ fun ActiveSessionBanner( gym: Gym?, onSessionClick: () -> Unit, onEndSession: () -> Unit, + modifier: Modifier = Modifier, ) { - if (activeSession != null) { - // Add a timer that updates every second for real-time duration counting - var currentTime by remember { mutableStateOf(LocalDateTime.now()) } + if (activeSession == null) return - LaunchedEffect(Unit) { - while (true) { - delay(1000) // Update every second - currentTime = LocalDateTime.now() - } + val sessionId = activeSession.id + val startTimeString = activeSession.startTime + val gymName = gym?.name ?: "Unknown Gym" + + 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 .fillMaxWidth() - .clickable { onSessionClick() }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - 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)) - + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = gym?.name ?: "Unknown Gym", - style = MaterialTheme.typography.bodyMedium, + text = "Active Session", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, 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( - onClick = onEndSession, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Icon( - imageVector = CustomIcons.Stop(MaterialTheme.colorScheme.onError), - contentDescription = "End session", + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = gymName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + if (startTimeString != null) { + 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 { - val startTime = LocalDateTime.parse(startTimeString) - val totalSeconds = ChronoUnit.SECONDS.between(startTime, currentTime) - val hours = totalSeconds / 3600 - 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" - } + val startTime = DateFormatUtils.parseISO8601(startTimeString) ?: return 0L + val now = Instant.now() + ChronoUnit.SECONDS.between(startTime, now).coerceAtLeast(0) } 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" } } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt index 8666f9a..7d4217b 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/AnalyticsScreen.kt @@ -27,6 +27,16 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) { val attempts by viewModel.attempts.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( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -65,18 +75,11 @@ fun AnalyticsScreen(viewModel: ClimbViewModel) { // Grade Distribution Chart item { - val gradeDistributionData = calculateGradeDistribution(sessions, problems, attempts) GradeDistributionChartCard(gradeDistributionData = gradeDistributionData) } // Favorite Gym item { - val favoriteGym = - sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let { - (gymId, sessions) -> - gyms.find { it.id == gymId }?.name to sessions.size - } - FavoriteGymCard( gymName = favoriteGym?.first ?: "No sessions yet", sessionCount = favoriteGym?.second ?: 0, diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt index 7d4cdfe..2385614 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt @@ -225,26 +225,24 @@ fun SessionDetailScreen( var showEditAttemptDialog by remember { mutableStateOf(null) } // Get session details - val session = sessions.find { it.id == sessionId } - val gym = session?.let { s -> gyms.find { it.id == s.gymId } } + val session = remember(sessions, sessionId) { sessions.find { it.id == sessionId } } + val gym = remember(session, gyms) { session?.let { s -> gyms.find { it.id == s.gymId } } } // Calculate stats - val successfulAttempts = + val successfulAttempts = remember(attempts) { 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 = + val attemptsWithProblems = remember(attempts, problems) { attempts .mapNotNull { attempt -> val problem = problems.find { it.id == attempt.problemId } if (problem != null) attempt to problem else null } - .sortedBy { attempt -> - // Sort by timestamp (when attempt was logged) - attempt.first.timestamp - } + .sortedBy { it.first.timestamp } + } Scaffold( topBar = { @@ -267,7 +265,7 @@ fun SessionDetailScreen( if (session?.status == SessionStatus.ACTIVE) { IconButton( onClick = { - session.let { s -> + session?.let { s -> viewModel.endSession(context, s.id) onNavigateBack() } @@ -313,8 +311,11 @@ fun SessionDetailScreen( Spacer(modifier = Modifier.height(8.dp)) + val formattedDate = remember(session?.date) { + DateFormatUtils.formatDateForDisplay(session?.date ?: "") + } Text( - text = formatDate(session?.date ?: ""), + text = formattedDate, style = MaterialTheme.typography.titleLarge, 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( - session = session, - gym = gym, - problems = problems.filter { it.gymId == gym.id && it.isActive }, + session = currentSession, + gym = currentGym, + problems = problems.filter { it.gymId == currentGym.id && it.isActive }, onDismiss = { showAddAttemptDialog = false }, onAttemptAdded = { attempt -> viewModel.addAttempt(attempt) @@ -728,9 +731,12 @@ fun ProblemDetailScreen( } firstSuccess?.let { attempt -> val session = sessions.find { it.id == attempt.sessionId } + val firstSuccessDate = remember(session?.date) { + DateFormatUtils.formatDateForDisplay(session?.date ?: "") + } 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, color = MaterialTheme.colorScheme.primary, ) @@ -1309,6 +1315,10 @@ fun StatItem(label: String, value: String) { @Composable fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { + val formattedDate = remember(session.date) { + DateFormatUtils.formatDateForDisplay(session.date) + } + Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -1318,7 +1328,7 @@ fun AttemptHistoryCard(attempt: Attempt, session: ClimbSession, gym: Gym?) { ) { Column { Text( - text = formatDate(session.date), + text = formattedDate, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, ) @@ -1478,9 +1488,7 @@ fun SessionAttemptCard( } } -private fun formatDate(dateString: String): String { - return DateFormatUtils.formatDateForDisplay(dateString) -} + @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt index 2464e1a..9d7a519 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/GymsScreen.kt @@ -52,7 +52,7 @@ fun GymsScreen(viewModel: ClimbViewModel, onNavigateToGymDetail: (String) -> Uni ) } else { LazyColumn { - items(gyms) { gym -> + items(gyms, key = { it.id }) { gym -> GymCard(gym = gym, onClick = { onNavigateToGymDetail(gym.id) }) Spacer(modifier = Modifier.height(8.dp)) } diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt index 7d8ac49..699d01d 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/ProblemsScreen.kt @@ -35,17 +35,18 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String var selectedGym by remember { mutableStateOf(null) } // Apply filters - val filteredProblems = + val filteredProblems = remember(problems, selectedClimbType, selectedGym) { problems.filter { problem -> val climbTypeMatch = selectedClimbType?.let { it == problem.climbType } != false val gymMatch = selectedGym?.let { it.id == problem.gymId } != false climbTypeMatch && gymMatch } + } // Separate active and inactive problems - val activeProblems = filteredProblems.filter { it.isActive } - val inactiveProblems = filteredProblems.filter { !it.isActive } - val sortedProblems = activeProblems + inactiveProblems + val activeProblems = remember(filteredProblems) { filteredProblems.filter { it.isActive } } + val inactiveProblems = remember(filteredProblems) { filteredProblems.filter { !it.isActive } } + val sortedProblems = remember(activeProblems, inactiveProblems) { activeProblems + inactiveProblems } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row( @@ -175,7 +176,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String ) } else { LazyColumn { - items(sortedProblems) { problem -> + items(sortedProblems, key = { it.id }) { problem -> ProblemCard( problem = problem, gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym", diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt index d3138bb..b92c650 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt @@ -61,8 +61,12 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String var selectedMonth by remember { mutableStateOf(YearMonth.now()) } var selectedDate by remember { mutableStateOf(LocalDate.now()) } - val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } - val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } + val completedSessions = remember(sessions) { + 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)) { Row( @@ -136,7 +140,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String when (viewMode) { ViewMode.LIST -> { LazyColumn { - items(completedSessions) { session -> + items(completedSessions, key = { it.id }) { session -> SessionCard( session = session, gymName = gyms.find { it.id == session.gymId }?.name @@ -232,6 +236,10 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String @OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) { + val formattedDate = remember(session.date) { + DateFormatUtils.formatDateForDisplay(session.date) + } + Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( @@ -244,7 +252,7 @@ fun SessionCard(session: ClimbSession, gymName: String, onClick: () -> Unit) { fontWeight = FontWeight.Bold, ) Text( - text = formatDate(session.date), + text = formattedDate, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -539,7 +547,3 @@ fun CalendarDay( } } } - -private fun formatDate(dateString: String): String { - return DateFormatUtils.formatDateForDisplay(dateString) -} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index f99e0ab..832f397 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.12.3" -kotlin = "2.2.21" +kotlin = "2.3.0" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -10,8 +10,8 @@ androidxTestExt = "1.3.0" androidxTestRunner = "1.7.0" androidxTestRules = "1.7.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.0" -composeBom = "2025.11.01" +activityCompose = "1.12.2" +composeBom = "2025.12.01" room = "2.8.4" navigation = "2.9.6" viewmodel = "2.10.0" @@ -19,10 +19,10 @@ kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" ksp = "2.2.20-2.0.3" -exifinterface = "1.4.1" -healthConnect = "1.1.0-alpha07" -detekt = "1.23.7" -spotless = "6.25.0" +exifinterface = "1.4.2" +healthConnect = "1.1.0" +detekt = "1.23.8" +spotless = "8.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index be58a1b..0aa3495 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -487,7 +487,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -535,7 +535,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -613,7 +613,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 40; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -643,7 +643,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 5b46cb9..66068d0 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Views/AnalyticsView.swift b/ios/Ascently/Views/AnalyticsView.swift index a45405e..3f0b70d 100644 --- a/ios/Ascently/Views/AnalyticsView.swift +++ b/ios/Ascently/Views/AnalyticsView.swift @@ -259,7 +259,7 @@ struct ProgressChartSection: View { .font(.title) .foregroundColor(.secondary) - Text("No data available for \(selectedSystem.displayName) system") + Text("No data available.") .font(.subheadline) .foregroundColor(.secondary) } diff --git a/ios/Ascently/Views/Detail/GymDetailView.swift b/ios/Ascently/Views/Detail/GymDetailView.swift index bbf696f..410ec4f 100644 --- a/ios/Ascently/Views/Detail/GymDetailView.swift +++ b/ios/Ascently/Views/Detail/GymDetailView.swift @@ -57,34 +57,29 @@ struct GymDetailView: View { .navigationTitle(gym?.name ?? "Gym Details") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItem(placement: .navigationBarTrailing) { if gym != nil { - Menu { - Button { - // Navigate to edit view - } label: { - Label("Edit Gym", systemImage: "pencil") - } - - Button(role: .destructive) { - showingDeleteAlert = true - } label: { - Label("Delete Gym", systemImage: "trash") - } + Button { + showingDeleteAlert = true } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "trash") } + .tint(.red) } } } - .alert("Delete Gym", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} + .confirmationDialog( + "Delete Gym", + isPresented: $showingDeleteAlert, + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let gym = gym { dataManager.deleteGym(gym) dismiss() } } + Button("Cancel", role: .cancel) {} } message: { Text( "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 { VStack(alignment: .leading, spacing: 8) { Text("Climb Types") @@ -157,7 +151,6 @@ struct GymHeaderCard: View { } } - // Difficulty Systems if !gym.difficultySystems.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Difficulty Systems") @@ -330,6 +323,12 @@ struct SessionRowCard: View { let session: ClimbSession @EnvironmentObject var dataManager: ClimbingDataManager + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + private var sessionAttempts: [Attempt] { 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) .foregroundColor(.secondary) } @@ -377,12 +376,6 @@ struct SessionRowCard: View { .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 { diff --git a/ios/Ascently/Views/Detail/ProblemDetailView.swift b/ios/Ascently/Views/Detail/ProblemDetailView.swift index e25ab3b..d676ff5 100644 --- a/ios/Ascently/Views/Detail/ProblemDetailView.swift +++ b/ios/Ascently/Views/Detail/ProblemDetailView.swift @@ -61,7 +61,7 @@ struct ProblemDetailView: View { .navigationTitle("Problem Details") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItem(placement: .navigationBarTrailing) { if problem != nil { Menu { Button { @@ -81,14 +81,18 @@ struct ProblemDetailView: View { } } } - .alert("Delete Problem", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} + .confirmationDialog( + "Delete Problem", + isPresented: $showingDeleteAlert, + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let problem = problem { dataManager.deleteProblem(problem) dismiss() } } + Button("Cancel", role: .cancel) {} } message: { Text( "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)? @EnvironmentObject var themeManager: ThemeManager + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Progress Summary") @@ -251,7 +261,7 @@ struct ProgressSummaryCard: View { .fontWeight(.medium) Text( - "\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))" + "\(Self.dateFormatter.string(from: firstSuccess.date)) (\(firstSuccess.result.displayName))" ) .font(.subheadline) .foregroundColor(themeManager.accentColor) @@ -266,12 +276,6 @@ struct ProgressSummaryCard: View { .fill(.regularMaterial) ) } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: date) - } } struct PhotosSection: View { @@ -360,6 +364,12 @@ struct AttemptHistoryCard: View { let session: ClimbSession @EnvironmentObject var dataManager: ClimbingDataManager + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + private var gym: Gym? { dataManager.gym(withId: session.gymId) } @@ -368,7 +378,7 @@ struct AttemptHistoryCard: View { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { - Text(formatDate(session.date)) + Text(Self.dateFormatter.string(from: session.date)) .font(.headline) .fontWeight(.semibold) @@ -403,12 +413,6 @@ struct AttemptHistoryCard: View { .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 { diff --git a/ios/Ascently/Views/Detail/SessionDetailView.swift b/ios/Ascently/Views/Detail/SessionDetailView.swift index d522c85..8b7fb66 100644 --- a/ios/Ascently/Views/Detail/SessionDetailView.swift +++ b/ios/Ascently/Views/Detail/SessionDetailView.swift @@ -46,7 +46,7 @@ struct SessionDetailView: View { .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - + if session.status == .active && musicService.isMusicEnabled && musicService.isAuthorized { MusicControlCard() .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) @@ -134,7 +134,7 @@ struct SessionDetailView: View { .navigationTitle("Session Details") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItem(placement: .navigationBarTrailing) { if let session = session { if session.status == .active { Button("End Session") { @@ -143,15 +143,12 @@ struct SessionDetailView: View { } .foregroundColor(.orange) } else { - Menu { - Button(role: .destructive) { - showingDeleteAlert = true - } label: { - Label("Delete Session", systemImage: "trash") - } + Button { + showingDeleteAlert = true } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "trash") } + .tint(.red) } } } @@ -188,7 +185,7 @@ struct SessionDetailView: View { Button(action: { showingAddAttempt = true }) { Image(systemName: "plus") .font(.title2) - .foregroundColor(.white) // Keep white for contrast on colored button + .foregroundColor(.white) .frame(width: 56, height: 56) .background(Circle().fill(themeManager.accentColor)) .shadow(radius: 4) @@ -196,14 +193,18 @@ struct SessionDetailView: View { .padding() } } - .alert("Delete Session", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} + .confirmationDialog( + "Delete Session", + isPresented: $showingDeleteAlert, + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let session = session { dataManager.deleteSession(session) dismiss() } } + Button("Cancel", role: .cancel) {} } message: { Text( "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 @EnvironmentObject var themeManager: ThemeManager + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .full + return formatter + }() + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { @@ -246,7 +253,7 @@ struct SessionHeaderCard: View { .font(.title) .fontWeight(.bold) - Text(formatDate(session.date)) + Text(Self.dateFormatter.string(from: session.date)) .font(.title2) .foregroundColor(themeManager.accentColor) @@ -273,7 +280,6 @@ struct SessionHeaderCard: View { } } - // Status indicator HStack { Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill") .foregroundColor(session.status == .active ? .green : themeManager.accentColor) @@ -298,12 +304,6 @@ struct SessionHeaderCard: View { .fill(.regularMaterial) ) } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .full - return formatter.string(from: date) - } } struct SessionStatsCard: View { @@ -356,8 +356,6 @@ struct StatItem: View { } } -// AttemptsSection removed as it is now integrated into the main List - struct AttemptCard: View { let attempt: Attempt let problem: Problem @@ -404,7 +402,7 @@ struct AttemptCard: View { .padding() .background( 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) ) } @@ -451,7 +449,7 @@ struct SessionStats { struct MusicControlCard: View { @EnvironmentObject var musicService: MusicService - + var body: some View { HStack { Image(systemName: "music.note") @@ -460,11 +458,11 @@ struct MusicControlCard: View { .frame(width: 40, height: 40) .background(Color.pink.opacity(0.1)) .clipShape(Circle()) - + VStack(alignment: .leading, spacing: 2) { Text("Music") .font(.headline) - + if let playlistId = musicService.selectedPlaylistId, let playlist = musicService.playlists.first(where: { $0.id.rawValue == playlistId }) { Text(playlist.name) @@ -476,9 +474,9 @@ struct MusicControlCard: View { .foregroundColor(.secondary) } } - + Spacer() - + Button(action: { musicService.togglePlayback() }) { diff --git a/ios/Ascently/Views/GymsView.swift b/ios/Ascently/Views/GymsView.swift index 3590e15..1845c88 100644 --- a/ios/Ascently/Views/GymsView.swift +++ b/ios/Ascently/Views/GymsView.swift @@ -77,16 +77,23 @@ struct GymsList: View { .tint(.indigo) } } - .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { - Button("Cancel", role: .cancel) { - gymToDelete = nil - } + .confirmationDialog( + "Delete Gym", + isPresented: .init( + get: { gymToDelete != nil }, + set: { if !$0 { gymToDelete = nil } } + ), + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let gym = gymToDelete { dataManager.deleteGym(gym) gymToDelete = nil } } + Button("Cancel", role: .cancel) { + gymToDelete = nil + } } message: { Text( "Are you sure you want to delete this gym? This will also delete all associated problems and sessions." diff --git a/ios/Ascently/Views/ProblemsView.swift b/ios/Ascently/Views/ProblemsView.swift index 611fc1f..bb5dc16 100644 --- a/ios/Ascently/Views/ProblemsView.swift +++ b/ios/Ascently/Views/ProblemsView.swift @@ -80,7 +80,7 @@ struct ProblemsView: View { if cachedFilteredProblems.isEmpty { VStack(spacing: 0) { headerContent - + EmptyProblemsView( isEmpty: dataManager.problems.isEmpty, isFiltered: !dataManager.problems.isEmpty @@ -209,16 +209,23 @@ struct ProblemsView: View { .sheet(item: $problemToEdit) { problem in AddEditProblemView(problemId: problem.id) } - .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { - Button("Cancel", role: .cancel) { - problemToDelete = nil - } + .confirmationDialog( + "Delete Problem", + isPresented: .init( + get: { problemToDelete != nil }, + set: { if !$0 { problemToDelete = nil } } + ), + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let problem = problemToDelete { dataManager.deleteProblem(problem) problemToDelete = nil } } + Button("Cancel", role: .cancel) { + problemToDelete = nil + } } message: { Text( "Are you sure you want to delete this problem? This will also delete all associated attempts." @@ -550,7 +557,7 @@ struct FilterSheet: View { let filteredProblems: [Problem] @Environment(\.dismiss) var dismiss @EnvironmentObject var themeManager: ThemeManager - + var body: some View { NavigationStack { ScrollView { @@ -582,7 +589,7 @@ struct FilterSheet: View { .foregroundColor(themeManager.accentColor) } } - + ToolbarItem(placement: .navigationBarLeading) { if selectedClimbType != nil || selectedGym != nil { Button(action: { diff --git a/ios/Ascently/Views/SessionsView.swift b/ios/Ascently/Views/SessionsView.swift index 18dfafc..7c34c73 100644 --- a/ios/Ascently/Views/SessionsView.swift +++ b/ios/Ascently/Views/SessionsView.swift @@ -62,7 +62,6 @@ struct SessionsView: View { ) } - // View mode toggle if !dataManager.sessions.isEmpty || dataManager.activeSession != nil { Button(action: { withAnimation(.easeInOut(duration: 0.2)) { @@ -116,7 +115,6 @@ struct SessionsList: View { var body: some View { List { - // Active session banner section if let activeSession = dataManager.activeSession, let gym = dataManager.gym(withId: activeSession.gymId) { @@ -130,7 +128,6 @@ struct SessionsList: View { } } - // Completed sessions section if !completedSessions.isEmpty { Section { ForEach(completedSessions) { session in @@ -156,16 +153,23 @@ struct SessionsList: View { } } .listStyle(.insetGrouped) - .alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) { - Button("Cancel", role: .cancel) { - sessionToDelete = nil - } + .confirmationDialog( + "Delete Session", + isPresented: .init( + get: { sessionToDelete != nil }, + set: { if !$0 { sessionToDelete = nil } } + ), + titleVisibility: .visible + ) { Button("Delete", role: .destructive) { if let session = sessionToDelete { dataManager.deleteSession(session) sessionToDelete = nil } } + Button("Cancel", role: .cancel) { + sessionToDelete = nil + } } message: { Text( "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 gym: Gym @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 + @State private var navigateToDetail = false var body: some View { HStack { @@ -213,7 +207,7 @@ struct ActiveSessionBanner: View { .foregroundColor(.secondary) .monospacedDigit() } - + if musicService.isMusicEnabled && musicService.isAuthorized { Button(action: { musicService.togglePlayback() @@ -251,7 +245,6 @@ struct ActiveSessionBanner: View { .fill(.green.opacity(0.1)) .stroke(.green.opacity(0.3), lineWidth: 1) ) - .navigationDestination(isPresented: $navigateToDetail) { SessionDetailView(sessionId: session.id) } @@ -262,6 +255,12 @@ struct SessionRow: View { let session: ClimbSession @EnvironmentObject var dataManager: ClimbingDataManager + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + private var gym: Gym? { dataManager.gym(withId: session.gymId) } @@ -275,7 +274,7 @@ struct SessionRow: View { Spacer() - Text(formatDate(session.date)) + Text(Self.dateFormatter.string(from: session.date)) .font(.caption) .foregroundColor(.secondary) } @@ -295,12 +294,6 @@ struct SessionRow: View { } .padding(.vertical, 8) } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: date) - } } struct EmptySessionsView: View {