iOS and Android dependency updates and optimizations
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -225,26 +225,24 @@ fun SessionDetailScreen(
|
||||
var showEditAttemptDialog by remember { mutableStateOf<Attempt?>(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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -35,17 +35,18 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
var selectedGym by remember { mutableStateOf<Gym?>(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",
|
||||
|
||||
@@ -61,8 +61,12 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||
var selectedDate by remember { mutableStateOf<LocalDate?>(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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user