Compare commits

...

10 Commits

Author SHA1 Message Date
cacd178817 iOS 2.4.1 - Minor Visual Tweaks 2025-12-03 00:10:08 -07:00
922412c2c2 Bumped build 2025-12-02 17:09:18 -07:00
acb1b1f532 2.4.0 - Updated Sync Architecture (Provider pattern) 2025-12-02 17:07:52 -07:00
c8694eacab iOS 2.4.0 - Colour accents and theming 2025-12-02 15:55:48 -07:00
57855b8332 Docs updates
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 5m14s
2025-12-01 17:07:30 -07:00
6342bfed5c 2.3.1 - Dependency Updates, Better Live Notifications, and Calendar Fixes 2025-12-01 11:46:31 -07:00
869ca0fc0d Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s
Reviewed-on: #6
2025-11-21 04:01:43 +00:00
33562e9d16 Merge branch 'main' into logging
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 7m42s
2025-11-21 04:01:16 +00:00
071e47f95e Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m18s
2025-10-25 09:41:27 +00:00
c6c3e6084b Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m19s
2025-10-25 09:33:33 +00:00
36 changed files with 2488 additions and 2121 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently" applicationId = "com.atridad.ascently"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 47
versionName = "2.3.0" versionName = "2.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -27,6 +27,7 @@
<!-- Permissions for notifications and foreground service --> <!-- Permissions for notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

View File

@@ -6,6 +6,8 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.atridad.ascently.MainActivity import com.atridad.ascently.MainActivity
@@ -15,6 +17,7 @@ import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -209,33 +212,8 @@ class SessionTrackingService : Service() {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
} }
val duration = val notificationBuilder =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
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"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
@@ -253,7 +231,64 @@ class SessionTrackingService : Service() {
"End Session", "End Session",
createStopPendingIntent(sessionId) createStopPendingIntent(sessionId)
) )
.build()
// Use Live Update
if (Build.VERSION.SDK_INT >= 36) {
val startTimeMillis =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val zoneId = ZoneId.systemDefault()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
}
?: System.currentTimeMillis()
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}${attempts.size} attempts"
)
.setWhen(startTimeMillis)
.setUsesChronometer(true)
.setShowWhen(true)
val extras = Bundle()
extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true)
notificationBuilder.setExtras(extras)
} else {
// Fallback for older versions
val duration =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
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"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
}
val notification = notificationBuilder.build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)

View File

@@ -5,8 +5,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -38,6 +36,7 @@ import java.time.YearMonth
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
import androidx.core.content.edit
enum class ViewMode { enum class ViewMode {
LIST, LIST,
@@ -60,7 +59,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST) mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
} }
var selectedMonth by remember { mutableStateOf(YearMonth.now()) } var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
var selectedDate by remember { mutableStateOf<LocalDate?>(null) } var selectedDate by remember { mutableStateOf<LocalDate?>(LocalDate.now()) }
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED } val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } } val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
@@ -89,7 +88,7 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
viewMode = viewMode =
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
selectedDate = null selectedDate = null
sharedPreferences.edit().putString("view_mode", viewMode.name).apply() sharedPreferences.edit { putString("view_mode", viewMode.name) }
} }
) { ) {
Icon( Icon(
@@ -147,16 +146,11 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
CalendarView( CalendarView(
sessions = completedSessions, sessions = completedSessions,
gyms = gyms, gyms = gyms,
activeSession = activeSession, selectedMonth = selectedMonth,
activeSessionGym = activeSessionGym,
selectedMonth = selectedMonth,
onMonthChange = { selectedMonth = it }, onMonthChange = { selectedMonth = it },
selectedDate = selectedDate, selectedDate = selectedDate,
onDateSelected = { selectedDate = it }, onDateSelected = { selectedDate = it },
onNavigateToSessionDetail = onNavigateToSessionDetail, onNavigateToSessionDetail = onNavigateToSessionDetail
onEndSession = {
activeSession?.let { viewModel.endSession(context, it.id) }
}
) )
} }
} }
@@ -315,14 +309,11 @@ fun EmptyStateMessage(
fun CalendarView( fun CalendarView(
sessions: List<ClimbSession>, sessions: List<ClimbSession>,
gyms: List<com.atridad.ascently.data.model.Gym>, gyms: List<com.atridad.ascently.data.model.Gym>,
activeSession: ClimbSession?,
activeSessionGym: com.atridad.ascently.data.model.Gym?,
selectedMonth: YearMonth, selectedMonth: YearMonth,
onMonthChange: (YearMonth) -> Unit, onMonthChange: (YearMonth) -> Unit,
selectedDate: LocalDate?, selectedDate: LocalDate?,
onDateSelected: (LocalDate?) -> Unit, onDateSelected: (LocalDate?) -> Unit,
onNavigateToSessionDetail: (String) -> Unit, onNavigateToSessionDetail: (String) -> Unit
onEndSession: () -> Unit
) { ) {
val sessionsByDate = val sessionsByDate =
remember(sessions) { remember(sessions) {
@@ -331,144 +322,155 @@ fun CalendarView(
java.time.Instant.parse(it.date) java.time.Instant.parse(it.date)
.atZone(java.time.ZoneId.systemDefault()) .atZone(java.time.ZoneId.systemDefault())
.toLocalDate() .toLocalDate()
} catch (e: Exception) { } catch (_: Exception) {
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE) LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
} }
} }
} }
Column(modifier = Modifier.fillMaxSize()) { val firstDayOfMonth = selectedMonth.atDay(1)
Card( val daysInMonth = selectedMonth.lengthOfMonth()
modifier = Modifier.fillMaxWidth(), val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
colors = val totalCells =
CardDefaults.cardColors( ((firstDayOfWeek + daysInMonth) / 7.0).let {
containerColor = MaterialTheme.colorScheme.surfaceVariant if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
) }
) { val numRows = totalCells / 7
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), LazyColumn(modifier = Modifier.fillMaxSize()) {
horizontalAlignment = Alignment.CenterHorizontally item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { ) {
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier =
horizontalArrangement = Arrangement.SpaceBetween, Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically horizontalAlignment = Alignment.CenterHorizontally
) { ) {
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) { Row(
Text("", style = MaterialTheme.typography.headlineMedium) modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
Text(
text =
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
Text("", style = MaterialTheme.typography.headlineMedium)
}
} }
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val today = LocalDate.now()
onMonthChange(YearMonth.from(today))
onDateSelected(today)
},
shape = RoundedCornerShape(50),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
Text( Text(
text = text = day,
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}", modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}
}
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) { Spacer(modifier = Modifier.height(8.dp))
Text("", style = MaterialTheme.typography.headlineMedium) }
items(numRows) { rowIndex ->
Row(modifier = Modifier.fillMaxWidth()) {
for (colIndex in 0 until 7) {
val index = rowIndex * 7 + colIndex
val dayNumber = index - firstDayOfWeek + 1
Box(modifier = Modifier.weight(1f)) {
if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
val isSelected = date == selectedDate
val isToday = date == LocalDate.now()
CalendarDay(
day = dayNumber,
hasSession = sessionsOnDate.isNotEmpty(),
isSelected = isSelected,
isToday = isToday,
onClick = {
if (sessionsOnDate.isNotEmpty()) {
onDateSelected(if (isSelected) null else date)
}
}
)
} else {
Spacer(modifier = Modifier.aspectRatio(1f))
}
} }
} }
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val today = LocalDate.now()
onMonthChange(YearMonth.from(today))
onDateSelected(today)
},
shape = RoundedCornerShape(50),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
) {
Text(
text = "Today",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
val firstDayOfMonth = selectedMonth.atDay(1)
val daysInMonth = selectedMonth.lengthOfMonth()
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
val totalCells =
((firstDayOfWeek + daysInMonth) / 7.0).let {
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
}
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
items(totalCells) { index ->
val dayNumber = index - firstDayOfWeek + 1
if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
val isSelected = date == selectedDate
val isToday = date == LocalDate.now()
CalendarDay(
day = dayNumber,
hasSession = sessionsOnDate.isNotEmpty(),
isSelected = isSelected,
isToday = isToday,
onClick = {
if (sessionsOnDate.isNotEmpty()) {
onDateSelected(if (isSelected) null else date)
}
}
)
} else {
Spacer(modifier = Modifier.aspectRatio(1f))
}
} }
} }
if (selectedDate != null) { if (selectedDate != null) {
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList() val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
Spacer(modifier = Modifier.height(16.dp)) item {
Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = text =
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}", "Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(sessionsOnSelectedDate) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
} }
items(sessionsOnSelectedDate) { session ->
SessionCard(
session = session,
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
onClick = { onNavigateToSessionDetail(session.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
item { Spacer(modifier = Modifier.height(16.dp)) }
} }
} }
} }

View File

@@ -583,41 +583,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
)
) {
ListItem(
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter =
painterResource(
id = R.drawable.ic_mountains
),
contentDescription = "Ascently Logo",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text("Ascently")
}
},
supportingContent = { Text("Track your climbing progress") },
leadingContent = {}
)
}
Spacer(modifier = Modifier.height(8.dp))
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors =

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.12.3" agp = "8.12.3"
kotlin = "2.2.20" kotlin = "2.2.21"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
@@ -9,17 +9,17 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0" androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0" androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.11.0" activityCompose = "1.12.0"
composeBom = "2025.10.00" composeBom = "2025.11.01"
room = "2.8.2" room = "2.8.4"
navigation = "2.9.5" navigation = "2.9.6"
viewmodel = "2.9.4" viewmodel = "2.10.0"
kotlinxSerialization = "1.9.0" 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.3.6" exifinterface = "1.4.1"
[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

@@ -1,7 +1,7 @@
{ {
"name": "ascently-docs", "name": "ascently-docs",
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.1.0",
"description": "Documentation site for Ascently - FOSS climbing tracking app", "description": "Documentation site for Ascently - FOSS climbing tracking app",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -26,8 +26,8 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.1", "@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.36.2", "@astrojs/starlight": "^0.37.0",
"astro": "^5.16.0", "astro": "^5.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },

147
docs/pnpm-lock.yaml generated
View File

@@ -10,13 +10,13 @@ importers:
dependencies: dependencies:
'@astrojs/node': '@astrojs/node':
specifier: ^9.5.1 specifier: ^9.5.1
version: 9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) version: 9.5.1(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))
'@astrojs/starlight': '@astrojs/starlight':
specifier: ^0.36.2 specifier: ^0.37.0
version: 0.36.2(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) version: 0.37.0(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))
astro: astro:
specifier: ^5.16.0 specifier: ^5.16.3
version: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) version: 5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
@@ -57,8 +57,8 @@ packages:
'@astrojs/sitemap@3.6.0': '@astrojs/sitemap@3.6.0':
resolution: {integrity: sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==} resolution: {integrity: sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==}
'@astrojs/starlight@0.36.2': '@astrojs/starlight@0.37.0':
resolution: {integrity: sha512-QR8NfO7+7DR13kBikhQwAj3IAoptLLNs9DkyKko2M2l3PrqpcpVUnw1JBJ0msGDIwE6tBbua2UeBND48mkh03w==} resolution: {integrity: sha512-1AlaEjYYRO+5o6P5maPUBQZr6Q3wtuhMQTmsDQExI07wJVwe7EC2wGhXnFo+jpCjwHv/Bdg33PQheY4UhMj01g==}
peerDependencies: peerDependencies:
astro: ^5.5.0 astro: ^5.5.0
@@ -564,23 +564,23 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@shikijs/core@3.15.0': '@shikijs/core@3.17.1':
resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} resolution: {integrity: sha512-VWsduykcibGU0WMi66PflThDWyqEeTOiWdCRa3wmsZuishh+1PDSOh5gGxHdSrOtS+v1pmYaxodk/JNzwusElA==}
'@shikijs/engine-javascript@3.15.0': '@shikijs/engine-javascript@3.17.1':
resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==} resolution: {integrity: sha512-Ars0DVJITQrkOl5Swwy+94NL/BlOi/w1NSFbPGkcsln7Dv+M2qHaVpNHwdtWCC4/arzvjuHbyWBUsWExDHPDLw==}
'@shikijs/engine-oniguruma@3.15.0': '@shikijs/engine-oniguruma@3.17.1':
resolution: {integrity: sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==} resolution: {integrity: sha512-fsXPy4va/4iblEGS+22nP5V08IwwBcM+8xHUzSON0QmHm29/AJRghA95w9VDnxuwp9wOdJxEhfPkKp6vqcsN+w==}
'@shikijs/langs@3.15.0': '@shikijs/langs@3.17.1':
resolution: {integrity: sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==} resolution: {integrity: sha512-YTBVN+L2j7zBuOVjNZ2XiSNQEkm/7wZ1TSc5UO77GJPcg7Rk25WSscWA7y8pW7Bo25JIU0EWchUkq/UQjOJlJA==}
'@shikijs/themes@3.15.0': '@shikijs/themes@3.17.1':
resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==} resolution: {integrity: sha512-aohwwqNUB5h2ATfgrqYRPl8vyazqCiQ2wIV4xq+UzaBRHpqLMGSemkasK+vIEpl0YaendoaKUsDfpwhCqyHIaQ==}
'@shikijs/types@3.15.0': '@shikijs/types@3.17.1':
resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==} resolution: {integrity: sha512-yUFLiCnZHHJ16KbVbt3B1EzBUadU3OVpq0PEyb301m5BbuFKApQYBzJGhrK48hH/tYWSjzwcj7BSmYbBc0zntQ==}
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -694,8 +694,8 @@ packages:
peerDependencies: peerDependencies:
astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0
astro@5.16.0: astro@5.16.3:
resolution: {integrity: sha512-GaDRs2Mngpw3dr2vc085GnORh98NiXxwIjg/EoQQQl/icZt3Z7s0BRsYHDZ8swkZbOA6wZsqWJdrNirl+iKcDg==} resolution: {integrity: sha512-KzDk41F9Dspf5fM/Ls4XZhV4/csjJcWBrlenbnp5V3NGwU1zEaJz/HIyrdKdf5yw+FgwCeD2+Yos1Xkx9gnI0A==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'}
hasBin: true hasBin: true
@@ -801,8 +801,8 @@ packages:
cookie-es@1.2.2: cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
cookie@1.0.2: cookie@1.1.1:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
crossws@0.3.5: crossws@0.3.5:
@@ -1246,8 +1246,8 @@ packages:
mdast-util-phrasing@4.1.0: mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
mdast-util-to-hast@13.2.0: mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
mdast-util-to-markdown@2.1.2: mdast-util-to-markdown@2.1.2:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@@ -1422,8 +1422,8 @@ packages:
oniguruma-parser@0.12.1: oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.3: oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
p-limit@2.3.0: p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
@@ -1449,8 +1449,8 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
package-manager-detector@1.5.0: package-manager-detector@1.6.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pagefind@1.4.0: pagefind@1.4.0:
resolution: {integrity: sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==} resolution: {integrity: sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==}
@@ -1652,8 +1652,8 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shiki@3.15.0: shiki@3.17.1:
resolution: {integrity: sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==} resolution: {integrity: sha512-KbAPJo6pQpfjupOg5HW0fk/OSmeBfzza2IjZ5XbNKbqhZaCoxro/EyOgesaLvTdyDfrsAUDA6L4q14sc+k9i7g==}
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -2025,7 +2025,7 @@ snapshots:
remark-parse: 11.0.0 remark-parse: 11.0.0
remark-rehype: 11.1.2 remark-rehype: 11.1.2
remark-smartypants: 3.0.2 remark-smartypants: 3.0.2
shiki: 3.15.0 shiki: 3.17.1
smol-toml: 1.5.2 smol-toml: 1.5.2
unified: 11.0.5 unified: 11.0.5
unist-util-remove-position: 5.0.0 unist-util-remove-position: 5.0.0
@@ -2035,12 +2035,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': '@astrojs/mdx@4.3.12(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))':
dependencies: dependencies:
'@astrojs/markdown-remark': 6.3.9 '@astrojs/markdown-remark': 6.3.9
'@mdx-js/mdx': 3.1.1 '@mdx-js/mdx': 3.1.1
acorn: 8.15.0 acorn: 8.15.0
astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) astro: 5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
estree-util-visit: 2.0.0 estree-util-visit: 2.0.0
hast-util-to-html: 9.0.5 hast-util-to-html: 9.0.5
@@ -2054,10 +2054,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@astrojs/node@9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': '@astrojs/node@9.5.1(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))':
dependencies: dependencies:
'@astrojs/internal-helpers': 0.7.5 '@astrojs/internal-helpers': 0.7.5
astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) astro: 5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
send: 1.2.0 send: 1.2.0
server-destroy: 1.0.1 server-destroy: 1.0.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -2073,17 +2073,17 @@ snapshots:
stream-replace-string: 2.0.0 stream-replace-string: 2.0.0
zod: 3.25.76 zod: 3.25.76
'@astrojs/starlight@0.36.2(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))': '@astrojs/starlight@0.37.0(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))':
dependencies: dependencies:
'@astrojs/markdown-remark': 6.3.9 '@astrojs/markdown-remark': 6.3.9
'@astrojs/mdx': 4.3.12(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) '@astrojs/mdx': 4.3.12(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))
'@astrojs/sitemap': 3.6.0 '@astrojs/sitemap': 3.6.0
'@pagefind/default-ui': 1.4.0 '@pagefind/default-ui': 1.4.0
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/js-yaml': 4.0.9 '@types/js-yaml': 4.0.9
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) astro: 5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
astro-expressive-code: 0.41.3(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)) astro-expressive-code: 0.41.3(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))
bcp-47: 2.1.0 bcp-47: 2.1.0
hast-util-from-html: 2.0.3 hast-util-from-html: 2.0.3
hast-util-select: 6.0.4 hast-util-select: 6.0.4
@@ -2092,6 +2092,7 @@ snapshots:
i18next: 23.16.8 i18next: 23.16.8
js-yaml: 4.1.1 js-yaml: 4.1.1
klona: 2.0.6 klona: 2.0.6
magic-string: 0.30.21
mdast-util-directive: 3.1.0 mdast-util-directive: 3.1.0
mdast-util-to-markdown: 2.1.2 mdast-util-to-markdown: 2.1.2
mdast-util-to-string: 4.0.0 mdast-util-to-string: 4.0.0
@@ -2241,7 +2242,7 @@ snapshots:
'@expressive-code/plugin-shiki@0.41.3': '@expressive-code/plugin-shiki@0.41.3':
dependencies: dependencies:
'@expressive-code/core': 0.41.3 '@expressive-code/core': 0.41.3
shiki: 3.15.0 shiki: 3.17.1
'@expressive-code/plugin-text-markers@0.41.3': '@expressive-code/plugin-text-markers@0.41.3':
dependencies: dependencies:
@@ -2471,33 +2472,33 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.53.3': '@rollup/rollup-win32-x64-msvc@4.53.3':
optional: true optional: true
'@shikijs/core@3.15.0': '@shikijs/core@3.17.1':
dependencies: dependencies:
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4
hast-util-to-html: 9.0.5 hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.15.0': '@shikijs/engine-javascript@3.17.1':
dependencies: dependencies:
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.3 oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.15.0': '@shikijs/engine-oniguruma@3.17.1':
dependencies: dependencies:
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.15.0': '@shikijs/langs@3.17.1':
dependencies: dependencies:
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/themes@3.15.0': '@shikijs/themes@3.17.1':
dependencies: dependencies:
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/types@3.15.0': '@shikijs/types@3.17.1':
dependencies: dependencies:
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@@ -2595,12 +2596,12 @@ snapshots:
astring@1.9.0: {} astring@1.9.0: {}
astro-expressive-code@0.41.3(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)): astro-expressive-code@0.41.3(astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)):
dependencies: dependencies:
astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3) astro: 5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
rehype-expressive-code: 0.41.3 rehype-expressive-code: 0.41.3
astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3): astro@5.16.3(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.0 '@astrojs/compiler': 2.13.0
'@astrojs/internal-helpers': 0.7.5 '@astrojs/internal-helpers': 0.7.5
@@ -2616,7 +2617,7 @@ snapshots:
ci-info: 4.3.1 ci-info: 4.3.1
clsx: 2.1.1 clsx: 2.1.1
common-ancestor-path: 1.0.1 common-ancestor-path: 1.0.1
cookie: 1.0.2 cookie: 1.1.1
cssesc: 3.0.0 cssesc: 3.0.0
debug: 4.4.3 debug: 4.4.3
deterministic-object-hash: 2.0.2 deterministic-object-hash: 2.0.2
@@ -2640,13 +2641,13 @@ snapshots:
neotraverse: 0.6.18 neotraverse: 0.6.18
p-limit: 6.2.0 p-limit: 6.2.0
p-queue: 8.1.1 p-queue: 8.1.1
package-manager-detector: 1.5.0 package-manager-detector: 1.6.0
piccolore: 0.1.3 piccolore: 0.1.3
picomatch: 4.0.3 picomatch: 4.0.3
prompts: 2.4.2 prompts: 2.4.2
rehype: 13.0.2 rehype: 13.0.2
semver: 7.7.3 semver: 7.7.3
shiki: 3.15.0 shiki: 3.17.1
smol-toml: 1.5.2 smol-toml: 1.5.2
svgo: 4.0.0 svgo: 4.0.0
tinyexec: 1.0.2 tinyexec: 1.0.2
@@ -2785,7 +2786,7 @@ snapshots:
cookie-es@1.2.2: {} cookie-es@1.2.2: {}
cookie@1.0.2: {} cookie@1.1.1: {}
crossws@0.3.5: crossws@0.3.5:
dependencies: dependencies:
@@ -3116,7 +3117,7 @@ snapshots:
hast-util-from-parse5: 8.0.3 hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.0 hast-util-to-parse5: 8.0.0
html-void-elements: 3.0.0 html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0 mdast-util-to-hast: 13.2.1
parse5: 7.3.0 parse5: 7.3.0
unist-util-position: 5.0.0 unist-util-position: 5.0.0
unist-util-visit: 5.0.0 unist-util-visit: 5.0.0
@@ -3171,7 +3172,7 @@ snapshots:
comma-separated-tokens: 2.0.3 comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0 hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0 html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0 mdast-util-to-hast: 13.2.1
property-information: 7.1.0 property-information: 7.1.0
space-separated-tokens: 2.0.2 space-separated-tokens: 2.0.2
stringify-entities: 4.0.4 stringify-entities: 4.0.4
@@ -3468,7 +3469,7 @@ snapshots:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
unist-util-is: 6.0.1 unist-util-is: 6.0.1
mdast-util-to-hast@13.2.0: mdast-util-to-hast@13.2.1:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -3816,7 +3817,7 @@ snapshots:
oniguruma-parser@0.12.1: {} oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.3: oniguruma-to-es@4.3.4:
dependencies: dependencies:
oniguruma-parser: 0.12.1 oniguruma-parser: 0.12.1
regex: 6.0.1 regex: 6.0.1
@@ -3843,7 +3844,7 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
package-manager-detector@1.5.0: {} package-manager-detector@1.6.0: {}
pagefind@1.4.0: pagefind@1.4.0:
optionalDependencies: optionalDependencies:
@@ -4051,7 +4052,7 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.0 mdast-util-to-hast: 13.2.1
unified: 11.0.5 unified: 11.0.5
vfile: 6.0.3 vfile: 6.0.3
@@ -4184,14 +4185,14 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
shiki@3.15.0: shiki@3.17.1:
dependencies: dependencies:
'@shikijs/core': 3.15.0 '@shikijs/core': 3.17.1
'@shikijs/engine-javascript': 3.15.0 '@shikijs/engine-javascript': 3.17.1
'@shikijs/engine-oniguruma': 3.15.0 '@shikijs/engine-oniguruma': 3.17.1
'@shikijs/langs': 3.15.0 '@shikijs/langs': 3.17.1
'@shikijs/themes': 3.15.0 '@shikijs/themes': 3.17.1
'@shikijs/types': 3.15.0 '@shikijs/types': 3.17.1
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4 '@types/hast': 3.0.4

View File

@@ -5,7 +5,7 @@ export const requirements = {
export const downloadLinks = { export const downloadLinks = {
android: { android: {
releases: "https://git.atri.dad/atridad/Ascently/releases", releases: "https://git.atri.dad/atridad/Ascently/tags?q=Android",
obtainium: obtainium:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases", "https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
playStore: "", playStore: "",

View File

@@ -5,7 +5,7 @@ description: Ascently's Privacy Policy
**Last updated: September 29, 2025** **Last updated: September 29, 2025**
This Privacy Policy describes our policies and procedures regarding the collection, use, and disclosure of your information when you use my software. This Privacy Policy describes my policies and procedures regarding the collection, use, and disclosure of your information when you use my software.
## No Data Collection ## No Data Collection
@@ -36,7 +36,7 @@ You may optionally integrate with Apple Health or Android Health Connect to impo
This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private. This software does not use cookies, tracking pixels, or any other analytics or tracking mechanisms. Your usage of the software is completely private.
## Contact Us ## Contact
If you have any questions about this Privacy Policy, you can contact me: If you have any questions about this Privacy Policy, you can contact me:

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 = 32; CURRENT_PROJECT_VERSION = 35;
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.3.0; MARKETING_VERSION = 2.4.1;
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 = 32; CURRENT_PROJECT_VERSION = 35;
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.3.0; MARKETING_VERSION = 2.4.1;
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 = 32; CURRENT_PROJECT_VERSION = 35;
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.3.0; MARKETING_VERSION = 2.4.1;
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 = 32; CURRENT_PROJECT_VERSION = 35;
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.3.0; MARKETING_VERSION = 2.4.1;
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

@@ -1,7 +1,6 @@
import AppIntents import AppIntents
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app. /// Defines the App Shortcuts available in the Shortcuts app.
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
struct AscentlyShortcuts: AppShortcutsProvider { struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor { static var shortcutTileColor: ShortcutTileColor {
@@ -11,23 +10,15 @@ struct AscentlyShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] { static var appShortcuts: [AppShortcut] {
return [ return [
AppShortcut( AppShortcut(
intent: StartLastGymSessionIntent(), intent: ToggleSessionIntent(),
phrases: [ phrases: [
"Start my climb in \(.applicationName)", "Toggle climb in \(.applicationName)",
"Begin my last gym session in \(.applicationName)", "Start or stop climb in \(.applicationName)",
"Climb toggle in \(.applicationName)",
], ],
shortTitle: "Start Climb", shortTitle: "Toggle Session",
systemImageName: "figure.climbing" systemImageName: "figure.climbing"
), )
AppShortcut(
intent: EndActiveSessionIntent(),
phrases: [
"Finish my climb in \(.applicationName)",
"End my session in \(.applicationName)",
],
shortTitle: "End Climb",
systemImageName: "flag.checkered"
),
] ]
} }
} }

View File

@@ -1,40 +0,0 @@
import AppIntents
import Foundation
/// Ends the currently active climbing session so logging stays in sync across devices.
/// Exposed to Shortcuts so users can wrap up a session without opening the app.
struct EndActiveSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"End Active Session"
}
static var description: IntentDescription {
IntentDescription(
"Stop the active climbing session and save its progress in Ascently."
)
}
static var openAppWhenRun: Bool {
false
}
func perform() async throws -> some IntentResult & ProvidesDialog {
do {
let summary = try await SessionIntentController().endActiveSession()
let dialog = IntentDialog("Session at \(summary.gymName) ended. Nice work!")
return .result(dialog: dialog)
} catch SessionIntentError.noActiveSession {
// No active session is fine - just return a friendly message
let dialog = IntentDialog("No active session to end.")
return .result(dialog: dialog)
} catch {
// Re-throw other errors
throw error
}
}
static var parameterSummary: some ParameterSummary {
Summary("End my current climbing session")
}
}

View File

@@ -27,7 +27,7 @@ struct SessionIntentSummary: Sendable {
let status: SessionStatus let status: SessionStatus
} }
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts. /// Controller for handling session operations from App Intents.
@MainActor @MainActor
final class SessionIntentController { final class SessionIntentController {
@@ -39,9 +39,9 @@ final class SessionIntentController {
/// Starts a new session using the most recently visited gym. /// Starts a new session using the most recently visited gym.
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary { func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Give a moment for data to be ready if app just launched // Wait for data to load
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds try? await Task.sleep(nanoseconds: 500_000_000)
} }
guard let lastGym = dataManager.getLastUsedGym() else { guard let lastGym = dataManager.getLastUsedGym() else {
@@ -89,7 +89,23 @@ final class SessionIntentController {
} }
private func logFailure(_ error: SessionIntentError, context: String) { private func logFailure(_ error: SessionIntentError, context: String) {
// Logging from intent context - errors are visible to user via dialog // Log error for debugging
print("SessionIntentError: \(error). Context: \(context)") print("SessionIntentError: \(error). Context: \(context)")
} }
/// Toggles the session state: ends active session if one exists, otherwise starts a new one.
func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
}
if dataManager.activeSession != nil {
let summary = try await endActiveSession()
return (summary, false)
} else {
let summary = try await startSessionWithLastUsedGym()
return (summary, true)
}
}
} }

View File

@@ -1,43 +0,0 @@
import AppIntents
import Foundation
/// Starts a climbing session at the most recently visited gym.
/// Exposed to Shortcuts so users can begin logging without opening the app.
struct StartLastGymSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"Start Last Gym Session"
}
static var description: IntentDescription {
IntentDescription(
"Begin a new climbing session using the most recent gym you visited in Ascently."
)
}
static var openAppWhenRun: Bool {
true
}
func perform() async throws -> some IntentResult & ProvidesDialog {
// Delay to ensure app has time to fully initialize if just launched
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
let summary = try await SessionIntentController().startSessionWithLastUsedGym()
// Give Live Activity extra time to start
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
return .result(
dialog: Self.successDialog(for: summary.gymName)
)
}
private static func successDialog(for gymName: String) -> IntentDialog {
IntentDialog("Session started at \(gymName). Have an awesome climb!")
}
static var parameterSummary: some ParameterSummary {
Summary("Start a session at my last gym")
}
}

View File

@@ -0,0 +1,40 @@
import AppIntents
import Foundation
/// Toggles the climbing session state: starts a session if none is active, or ends the current one.
struct ToggleSessionIntent: AppIntent {
static var title: LocalizedStringResource {
"Toggle Climbing Session"
}
static var description: IntentDescription {
IntentDescription(
"Starts a new session at your last gym if you're not climbing, or ends your current session if you are."
)
}
static var openAppWhenRun: Bool {
false
}
func perform() async throws -> some IntentResult & ProvidesDialog {
// Wait for app initialization
try? await Task.sleep(nanoseconds: 1_000_000_000)
let controller = await SessionIntentController()
let (summary, wasStarted) = try await controller.toggleSession()
if wasStarted {
// Wait for Live Activity
try? await Task.sleep(nanoseconds: 500_000_000)
return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!"))
} else {
return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!"))
}
}
static var parameterSummary: some ParameterSummary {
Summary("Toggle my climbing session")
}
}

View File

@@ -13,10 +13,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
struct AscentlyApp: App { struct AscentlyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@StateObject private var themeManager = ThemeManager()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(themeManager)
.tint(themeManager.accentColor)
} }
} }
} }

View File

@@ -8,6 +8,7 @@ struct PhotoOptionSheet: View {
let onCameraSelected: () -> Void let onCameraSelected: () -> Void
let onPhotoLibrarySelected: () -> Void let onPhotoLibrarySelected: () -> Void
let onDismiss: () -> Void let onDismiss: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
NavigationView { NavigationView {
@@ -29,7 +30,7 @@ struct PhotoOptionSheet: View {
HStack { HStack {
Image(systemName: "photo.on.rectangle") Image(systemName: "photo.on.rectangle")
.font(.title2) .font(.title2)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Photo Library") Text("Photo Library")
.font(.headline) .font(.headline)
Spacer() Spacer()
@@ -52,7 +53,7 @@ struct PhotoOptionSheet: View {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.font(.title2) .font(.title2)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Camera") Text("Camera")
.font(.headline) .font(.headline)
Spacer() Spacer()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
import Foundation
struct SyncMerger {
private static let logTag = "SyncMerger"
static func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
dataManager: ClimbingDataManager,
imagePathMapping: [String: String]
) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) {
// Merge deletion lists first to prevent resurrection of deleted items
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
AppLogger.info("Merging gyms...", tag: logTag)
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: uniqueDeletions)
AppLogger.info("Merging problems...", tag: logTag)
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions)
AppLogger.info("Merging sessions...", tag: logTag)
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: uniqueDeletions)
AppLogger.info("Merging attempts...", tag: logTag)
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: uniqueDeletions)
return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions)
}
private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] {
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
let localGymIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = localGymIds.contains(serverGym.id)
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
merged.append(serverGymConverted)
}
}
}
return merged
}
private static func mergeProblems(
local: [Problem],
server: [BackupProblem],
imagePathMapping: [String: String],
deletedItems: [DeletedItem]
) throws -> [Problem] {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
let localProblemIds = Set(local.map { $0.id.uuidString })
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
for serverProblem in server {
let localHasProblem = localProblemIds.contains(serverProblem.id)
let isDeleted = deletedProblemIds.contains(serverProblem.id)
if !localHasProblem && !isDeleted {
var problemToAdd = serverProblem
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty {
let updatedImagePaths = imagePaths.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
if updatedImagePaths != imagePaths {
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
merged.append(serverProblemConverted)
}
}
}
return merged
}
private static func mergeSessions(
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
) throws -> [ClimbSession] {
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
let localSessionIds = Set(local.map { $0.id.uuidString })
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
for serverSession in server {
let localHasSession = localSessionIds.contains(serverSession.id)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
if let serverSessionConverted = try? serverSession.toClimbSession() {
merged.append(serverSessionConverted)
}
}
}
return merged
}
private static func mergeAttempts(
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
) throws -> [Attempt] {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let localAttemptIds = Set(local.map { $0.id.uuidString })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
return attempt.sessionId
}.filter { sessionId in
// Check if this session ID belongs to an active session
// For now, we'll be conservative and not delete attempts during merge
return true
})
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { attempt in
deletedAttemptIds.contains(attempt.id.uuidString)
&& !activeSessionIds.contains(attempt.sessionId)
}
for serverAttempt in server {
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
merged.append(serverAttemptConverted)
}
}
}
return merged
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
enum SyncProviderType: String, CaseIterable, Identifiable {
case none
case server
case iCloud
var id: String { rawValue }
var displayName: String {
switch self {
case .none: return "None"
case .server: return "Self-Hosted Server"
case .iCloud: return "iCloud"
}
}
}
protocol SyncProvider {
var type: SyncProviderType { get }
var isConfigured: Bool { get }
var isConnected: Bool { get }
func sync(dataManager: ClimbingDataManager) async throws
func testConnection() async throws
func disconnect()
}
enum SyncError: LocalizedError {
case notConfigured
case notConnected
case invalidURL
case invalidResponse
case unauthorized
case badRequest
case serverError(Int)
case decodingError(Error)
case exportFailed
case importFailed(Error)
case imageNotFound
case imageUploadFailed
case providerError(String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Sync server not configured. Please set server URL and auth token."
case .notConnected:
return "Not connected to sync server. Please test connection first."
case .invalidURL:
return "Invalid server URL."
case .invalidResponse:
return "Invalid response from server."
case .unauthorized:
return "Authentication failed. Check your auth token."
case .badRequest:
return "Bad request. Check your data format."
case .serverError(let code):
return "Server error (code \(code))."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .exportFailed:
return "Failed to export local data."
case .importFailed(let error):
return "Failed to import data: \(error.localizedDescription)"
case .imageNotFound:
return "Image not found on server."
case .imageUploadFailed:
return "Failed to upload image to server."
case .providerError(let message):
return "Sync provider error: \(message)"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import SwiftUI
import Combine
class ThemeManager: ObservableObject {
@Published var accentColor: Color = .blue {
didSet {
saveColor()
}
}
private let userDefaultsKey = "accentColorData"
init() {
loadColor()
}
private func loadColor() {
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey) else {
self.accentColor = .blue
return
}
do {
if let uiColor = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data) {
self.accentColor = Color(uiColor)
}
} catch {
print("Failed to load accent color: \(error)")
self.accentColor = .blue
}
}
private func saveColor() {
do {
let uiColor = UIColor(accentColor)
let data = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
UserDefaults.standard.set(data, forKey: userDefaultsKey)
} catch {
print("Failed to save accent color: \(error)")
}
}
func resetToDefault() {
accentColor = .blue
}
// Curated list of preset colors that maintain good contrast
static let presetColors: [Color] = [
.blue, // Default Blue
.purple, // Purple
.pink, // Pink
.red, // Red
.orange, // Orange
.green, // Green
.teal, // Teal
.indigo, // Indigo
.mint, // Mint
Color(uiColor: .systemBrown), // Brown
Color(uiColor: .systemCyan) // Cyan
]
var contrastingTextColor: Color {
let uiColor = UIColor(accentColor)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
// Calculate relative luminance
let luminance = 0.299 * red + 0.587 * green + 0.114 * blue
// Return black for light colors, white for dark colors
return luminance > 0.5 ? .black : .white
}
}

View File

@@ -5,6 +5,7 @@ struct AddAttemptView: View {
let session: ClimbSession let session: ClimbSession
let gym: Gym let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedProblem: Problem? @State private var selectedProblem: Problem?
@@ -158,6 +159,7 @@ struct AddAttemptView: View {
showingCreateProblem = true showingCreateProblem = true
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(themeManager.accentColor)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} else { } else {
@@ -179,7 +181,7 @@ struct AddAttemptView: View {
Button("Create New Problem") { Button("Create New Problem") {
showingCreateProblem = true showingCreateProblem = true
} }
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -198,7 +200,7 @@ struct AddAttemptView: View {
selectedPhotos = [] selectedPhotos = []
imageData = [] imageData = []
} }
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
@@ -213,7 +215,7 @@ struct AddAttemptView: View {
Spacer() Spacer()
if selectedClimbType == climbType { if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -238,7 +240,7 @@ struct AddAttemptView: View {
Spacer() Spacer()
if selectedDifficultySystem == system { if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -272,7 +274,7 @@ struct AddAttemptView: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
.tint(newProblemGrade == grade ? .blue : .gray) .tint(newProblemGrade == grade ? themeManager.accentColor : .gray)
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
@@ -287,12 +289,12 @@ struct AddAttemptView: View {
}) { }) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
.font(.title2) .font(.title2)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Add Photos") Text("Add Photos")
.font(.headline) .font(.headline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added") Text("\(imageData.count) of 5 photos added")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -353,7 +355,7 @@ struct AddAttemptView: View {
Spacer() Spacer()
if selectedResult == result { if selectedResult == result {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -529,6 +531,7 @@ struct ProblemSelectionRow: View {
let problem: Problem let problem: Problem
let isSelected: Bool let isSelected: Bool
let action: () -> Void let action: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
HStack { HStack {
@@ -539,7 +542,7 @@ struct ProblemSelectionRow: View {
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)") Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
if let location = problem.location { if let location = problem.location {
Text(location) Text(location)
@@ -552,7 +555,7 @@ struct ProblemSelectionRow: View {
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -569,6 +572,7 @@ struct ProblemSelectionCard: View {
let isSelected: Bool let isSelected: Bool
let action: () -> Void let action: () -> Void
@State private var showingExpandedView = false @State private var showingExpandedView = false
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
@@ -594,7 +598,7 @@ struct ProblemSelectionCard: View {
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.white) .foregroundColor(.white)
.background(Circle().fill(.blue)) .background(Circle().fill(themeManager.accentColor))
.font(.title3) .font(.title3)
} }
} }
@@ -634,7 +638,7 @@ struct ProblemSelectionCard: View {
Text(problem.difficulty.grade) Text(problem.difficulty.grade)
.font(.caption2) .font(.caption2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
if let location = problem.location { if let location = problem.location {
Text(location) Text(location)
@@ -648,8 +652,8 @@ struct ProblemSelectionCard: View {
.padding(8) .padding(8)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05)) .fill(isSelected ? themeManager.accentColor.opacity(0.1) : .gray.opacity(0.05))
.stroke(isSelected ? .blue : .clear, lineWidth: 2) .stroke(isSelected ? themeManager.accentColor : .clear, lineWidth: 2)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
@@ -668,6 +672,7 @@ struct ProblemSelectionCard: View {
struct ProblemExpandedView: View { struct ProblemExpandedView: View {
let problem: Problem let problem: Problem
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var themeManager: ThemeManager
@State private var selectedImageIndex = 0 @State private var selectedImageIndex = 0
var body: some View { var body: some View {
@@ -696,7 +701,7 @@ struct ProblemExpandedView: View {
Text(problem.difficulty.grade) Text(problem.difficulty.grade)
.font(.title3) .font(.title3)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text(problem.climbType.displayName) Text(problem.climbType.displayName)
.font(.subheadline) .font(.subheadline)
@@ -724,9 +729,9 @@ struct ProblemExpandedView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
.padding(.horizontal) .padding(.horizontal)
@@ -752,6 +757,7 @@ struct ProblemExpandedView: View {
struct EditAttemptView: View { struct EditAttemptView: View {
let attempt: Attempt let attempt: Attempt
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedProblem: Problem? @State private var selectedProblem: Problem?
@@ -926,6 +932,7 @@ struct EditAttemptView: View {
showingCreateProblem = true showingCreateProblem = true
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(themeManager.accentColor)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} else { } else {
@@ -947,7 +954,7 @@ struct EditAttemptView: View {
Button("Create New Problem") { Button("Create New Problem") {
showingCreateProblem = true showingCreateProblem = true
} }
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -966,7 +973,7 @@ struct EditAttemptView: View {
selectedPhotos = [] selectedPhotos = []
imageData = [] imageData = []
} }
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
@@ -981,7 +988,7 @@ struct EditAttemptView: View {
Spacer() Spacer()
if selectedClimbType == climbType { if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -1006,7 +1013,7 @@ struct EditAttemptView: View {
Spacer() Spacer()
if selectedDifficultySystem == system { if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -1040,7 +1047,7 @@ struct EditAttemptView: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
.tint(newProblemGrade == grade ? .blue : .gray) .tint(newProblemGrade == grade ? themeManager.accentColor : .gray)
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
@@ -1055,12 +1062,12 @@ struct EditAttemptView: View {
}) { }) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
.font(.title2) .font(.title2)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Add Photos") Text("Add Photos")
.font(.headline) .font(.headline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added") Text("\(imageData.count) of 5 photos added")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -1121,7 +1128,7 @@ struct EditAttemptView: View {
Spacer() Spacer()
if selectedResult == result { if selectedResult == result {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct AddEditGymView: View { struct AddEditGymView: View {
let gymId: UUID? let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@@ -83,7 +84,7 @@ struct AddEditGymView: View {
Spacer() Spacer()
if selectedClimbTypes.contains(climbType) { if selectedClimbTypes.contains(climbType) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -115,7 +116,7 @@ struct AddEditGymView: View {
Spacer() Spacer()
if selectedDifficultySystems.contains(system) { if selectedDifficultySystems.contains(system) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)

View File

@@ -5,6 +5,7 @@ struct AddEditProblemView: View {
let problemId: UUID? let problemId: UUID?
let gymId: UUID? let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym? @State private var selectedGym: Gym?
@@ -192,7 +193,7 @@ struct AddEditProblemView: View {
if selectedGym?.id == gym.id { if selectedGym?.id == gym.id {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -235,7 +236,7 @@ struct AddEditProblemView: View {
Spacer() Spacer()
if selectedClimbType == climbType { if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -264,7 +265,7 @@ struct AddEditProblemView: View {
Spacer() Spacer()
if selectedDifficultySystem == system { if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundColor(.gray) .foregroundColor(.gray)
@@ -337,7 +338,7 @@ struct AddEditProblemView: View {
} else { } else {
Text("Selected: \(difficultyGrade)") Text("Selected: \(difficultyGrade)")
.font(.caption) .font(.caption)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -372,12 +373,12 @@ struct AddEditProblemView: View {
}) { }) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
.font(.title2) .font(.title2)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Add Photos") Text("Add Photos")
.font(.headline) .font(.headline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added") Text("\(imageData.count) of 5 photos added")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct AddEditSessionView: View { struct AddEditSessionView: View {
let sessionId: UUID? let sessionId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym? @State private var selectedGym: Gym?
@@ -71,7 +72,7 @@ struct AddEditSessionView: View {
if selectedGym?.id == gym.id { if selectedGym?.id == gym.id {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct AnalyticsView: View { struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -25,7 +26,7 @@ struct AnalyticsView: View {
if dataManager.isSyncing { if dataManager.isSyncing {
HStack(spacing: 2) { HStack(spacing: 2) {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue)) .progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.scaleEffect(0.6) .scaleEffect(0.6)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -47,6 +48,7 @@ struct AnalyticsView: View {
struct OverallStatsSection: View { struct OverallStatsSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -59,7 +61,7 @@ struct OverallStatsSection: View {
title: "Sessions", title: "Sessions",
value: "\(dataManager.completedSessions().count)", value: "\(dataManager.completedSessions().count)",
icon: "play.fill", icon: "play.fill",
color: .blue color: themeManager.accentColor
) )
StatCard( StatCard(
@@ -117,13 +119,15 @@ struct StatCard: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial) .fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }
} }
struct ProgressChartSection: View { struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var selectedSystem: DifficultySystem = .vScale @State private var selectedSystem: DifficultySystem = .vScale
@State private var showAllTime: Bool = true @State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = [] @State private var cachedGradeCountData: [GradeCount] = []
@@ -178,10 +182,10 @@ struct ProgressChartSection: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear) .fill(showAllTime ? themeManager.accentColor : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1) .stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
) )
.foregroundColor(showAllTime ? .white : .blue) .foregroundColor(showAllTime ? themeManager.contrastingTextColor : themeManager.accentColor)
} }
Button(action: { Button(action: {
@@ -194,10 +198,10 @@ struct ProgressChartSection: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear) .fill(!showAllTime ? themeManager.accentColor : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1) .stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
) )
.foregroundColor(!showAllTime ? .white : .blue) .foregroundColor(!showAllTime ? themeManager.contrastingTextColor : themeManager.accentColor)
} }
} }
@@ -215,7 +219,7 @@ struct ProgressChartSection: View {
if selectedSystem == system { if selectedSystem == system {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -232,10 +236,10 @@ struct ProgressChartSection: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
.stroke(.blue.opacity(0.3), lineWidth: 1) .stroke(themeManager.accentColor.opacity(0.3), lineWidth: 1)
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -336,6 +340,7 @@ struct GradeCount {
struct BarChartView: View { struct BarChartView: View {
let data: [GradeCount] let data: [GradeCount]
@EnvironmentObject var themeManager: ThemeManager
private var sortedData: [GradeCount] { private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric } data.sorted { $0.gradeNumeric < $1.gradeNumeric }
@@ -367,7 +372,7 @@ struct BarChartView: View {
VStack(spacing: 4) { VStack(spacing: 4) {
// Bar // Bar
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(.blue) .fill(themeManager.accentColor)
.frame( .frame(
width: barWidth, width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount) height: CGFloat(gradeCount.count) / CGFloat(maxCount)
@@ -377,7 +382,7 @@ struct BarChartView: View {
Text("\(gradeCount.count)") Text("\(gradeCount.count)")
.font(.caption2) .font(.caption2)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.white) .foregroundColor(themeManager.contrastingTextColor)
.opacity(gradeCount.count > 0 ? 1 : 0) .opacity(gradeCount.count > 0 ? 1 : 0)
) )
@@ -471,6 +476,7 @@ struct FavoriteGymSection: View {
struct RecentActivitySection: View { struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
private var recentSessionsCount: Int { private var recentSessionsCount: Int {
dataManager.sessions.count dataManager.sessions.count
@@ -485,7 +491,7 @@ struct RecentActivitySection: View {
HStack { HStack {
Image(systemName: "clock.fill") Image(systemName: "clock.fill")
.font(.title2) .font(.title2)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Recent Activity") Text("Recent Activity")
.font(.title2) .font(.title2)
@@ -499,7 +505,7 @@ struct RecentActivitySection: View {
HStack { HStack {
Image(systemName: "play.circle") Image(systemName: "play.circle")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("\(recentSessionsCount) sessions") Text("\(recentSessionsCount) sessions")
.font(.subheadline) .font(.subheadline)

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct CalendarView: View { struct CalendarView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
let sessions: [ClimbSession] let sessions: [ClimbSession]
@Binding var selectedMonth: Date @Binding var selectedMonth: Date
@Binding var selectedDate: Date? @Binding var selectedDate: Date?
@@ -68,7 +69,7 @@ struct CalendarView: View {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.title2) .font(.title2)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
@@ -84,7 +85,7 @@ struct CalendarView: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.title2) .font(.title2)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
@@ -97,10 +98,10 @@ struct CalendarView: View {
Text("Today") Text("Today")
.font(.subheadline) .font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.white) .foregroundColor(themeManager.contrastingTextColor)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(Color.blue) .background(themeManager.accentColor)
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
@@ -209,6 +210,7 @@ struct CalendarDayCell: View {
let isToday: Bool let isToday: Bool
let isInCurrentMonth: Bool let isInCurrentMonth: Bool
let onTap: () -> Void let onTap: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var dayNumber: String { var dayNumber: String {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -224,9 +226,9 @@ struct CalendarDayCell: View {
.fontWeight(sessions.isEmpty ? .regular : .medium) .fontWeight(sessions.isEmpty ? .regular : .medium)
.foregroundColor( .foregroundColor(
isSelected isSelected
? .white ? themeManager.contrastingTextColor
: isToday : isToday
? .blue ? themeManager.accentColor
: !isInCurrentMonth : !isInCurrentMonth
? .secondary.opacity(0.3) ? .secondary.opacity(0.3)
: sessions.isEmpty ? .secondary : .primary : sessions.isEmpty ? .secondary : .primary
@@ -234,7 +236,7 @@ struct CalendarDayCell: View {
if !sessions.isEmpty { if !sessions.isEmpty {
Circle() Circle()
.fill(isSelected ? .white : .blue) .fill(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
.frame(width: 4, height: 4) .frame(width: 4, height: 4)
} else { } else {
Spacer() Spacer()
@@ -247,13 +249,13 @@ struct CalendarDayCell: View {
.background( .background(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill( .fill(
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear isSelected ? themeManager.accentColor : isToday ? themeManager.accentColor.opacity(0.1) : Color.clear
) )
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.stroke( .stroke(
isToday && !isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1 isToday && !isSelected ? themeManager.accentColor.opacity(0.3) : Color.clear, lineWidth: 1
) )
) )
} }

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct GymDetailView: View { struct GymDetailView: View {
let gymId: UUID let gymId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false @State private var showingDeleteAlert = false
@@ -108,6 +109,7 @@ struct GymDetailView: View {
struct GymHeaderCard: View { struct GymHeaderCard: View {
let gym: Gym let gym: Gym
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -145,9 +147,9 @@ struct GymHeaderCard: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
@@ -318,8 +320,8 @@ struct ProblemRowCard: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial) .fill(Color(uiColor: .secondarySystemGroupedBackground))
.stroke(.quaternary, lineWidth: 1) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }
} }
@@ -371,8 +373,8 @@ struct SessionRowCard: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial) .fill(Color(uiColor: .secondarySystemGroupedBackground))
.stroke(.quaternary, lineWidth: 1) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct ProblemDetailView: View { struct ProblemDetailView: View {
let problemId: UUID let problemId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false @State private var showingDeleteAlert = false
@State private var showingImageViewer = false @State private var showingImageViewer = false
@@ -125,6 +126,7 @@ struct ProblemDetailView: View {
struct ProblemHeaderCard: View { struct ProblemHeaderCard: View {
let problem: Problem let problem: Problem
let gym: Gym let gym: Gym
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -151,7 +153,7 @@ struct ProblemHeaderCard: View {
Text(problem.difficulty.grade) Text(problem.difficulty.grade)
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text(problem.climbType.displayName) Text(problem.climbType.displayName)
.font(.subheadline) .font(.subheadline)
@@ -178,9 +180,9 @@ struct ProblemHeaderCard: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
.padding(.horizontal, 1) .padding(.horizontal, 1)
@@ -223,6 +225,7 @@ struct ProgressSummaryCard: View {
let totalAttempts: Int let totalAttempts: Int
let successfulAttempts: Int let successfulAttempts: Int
let firstSuccess: (date: Date, result: AttemptResult)? let firstSuccess: (date: Date, result: AttemptResult)?
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -251,7 +254,7 @@ struct ProgressSummaryCard: View {
"\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))" "\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))"
) )
.font(.subheadline) .font(.subheadline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
.padding(.top, 8) .padding(.top, 8)
} }
@@ -396,7 +399,8 @@ struct AttemptHistoryCard: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial) .fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
) )
} }

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct SessionDetailView: View { struct SessionDetailView: View {
let sessionId: UUID let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false @State private var showingDeleteAlert = false
@State private var showingAddAttempt = false @State private var showingAddAttempt = false
@@ -35,26 +36,91 @@ struct SessionDetailView: View {
} }
var body: some View { var body: some View {
ScrollView { List {
LazyVStack(spacing: 20) { if let session = session, let gym = gym {
if let session = session, let gym = gym { Section {
SessionHeaderCard( SessionHeaderCard(
session: session, gym: gym, stats: sessionStats) session: session, gym: gym, stats: sessionStats)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 8)
SessionStatsCard(stats: sessionStats) SessionStatsCard(stats: sessionStats)
.listRowInsets(EdgeInsets())
AttemptsSection( .listRowBackground(Color.clear)
attemptsWithProblems: attemptsWithProblems, .listRowSeparator(.hidden)
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} else {
Text("Session not found")
.foregroundColor(.secondary)
} }
}
.padding()
}
Section {
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(themeManager.accentColor)
.accessibilityLabel("Edit attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
} header: {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.primary)
.textCase(nil)
.padding(.bottom, 8)
.padding(.top, 16)
}
} else {
Text("Session not found")
.foregroundColor(.secondary)
}
}
.listStyle(.plain)
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -112,9 +178,9 @@ struct SessionDetailView: View {
Button(action: { showingAddAttempt = true }) { Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.title2) .font(.title2)
.foregroundColor(.white) .foregroundColor(.white) // Keep white for contrast on colored button
.frame(width: 56, height: 56) .frame(width: 56, height: 56)
.background(Circle().fill(.blue)) .background(Circle().fill(themeManager.accentColor))
.shadow(radius: 4) .shadow(radius: 4)
} }
.padding() .padding()
@@ -162,6 +228,7 @@ struct SessionHeaderCard: View {
let session: ClimbSession let session: ClimbSession
let gym: Gym let gym: Gym
let stats: SessionStats let stats: SessionStats
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
@@ -172,7 +239,7 @@ struct SessionHeaderCard: View {
Text(formatDate(session.date)) Text(formatDate(session.date))
.font(.title2) .font(.title2)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
if session.status == .active { if session.status == .active {
if let startTime = session.startTime { if let startTime = session.startTime {
@@ -200,12 +267,12 @@ struct SessionHeaderCard: View {
// Status indicator // 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 : .blue) .foregroundColor(session.status == .active ? .green : themeManager.accentColor)
Text(session.status == .active ? "In Progress" : "Completed") Text(session.status == .active ? "In Progress" : "Completed")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(session.status == .active ? .green : .blue) .foregroundColor(session.status == .active ? .green : themeManager.accentColor)
Spacer() Spacer()
} }
@@ -213,7 +280,7 @@ struct SessionHeaderCard: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill((session.status == .active ? Color.green : Color.blue).opacity(0.1)) .fill((session.status == .active ? Color.green : themeManager.accentColor).opacity(0.1))
) )
} }
.padding() .padding()
@@ -264,13 +331,14 @@ struct SessionStatsCard: View {
struct StatItem: View { struct StatItem: View {
let label: String let label: String
let value: String let value: String
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text(value) Text(value)
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text(label) Text(label)
.font(.caption) .font(.caption)
@@ -280,85 +348,12 @@ struct StatItem: View {
} }
} }
struct AttemptsSection: View { // AttemptsSection removed as it is now integrated into the main List
let attemptsWithProblems: [(Attempt, Problem)]
@Binding var attemptToDelete: Attempt?
@Binding var editingAttempt: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
} else {
List {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
// Add haptic feedback for delete action
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
.accessibilityHint("Removes this attempt from the session")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
.accessibilityLabel("Edit attempt")
.accessibilityHint("Modify the details of this attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: CGFloat(attemptsWithProblems.count) * 120)
}
}
}
}
struct AttemptCard: View { struct AttemptCard: View {
let attempt: Attempt let attempt: Attempt
let problem: Problem let problem: Problem
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -370,7 +365,7 @@ struct AttemptCard: View {
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)") Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
if let location = problem.location { if let location = problem.location {
Text(location) Text(location)
@@ -399,9 +394,11 @@ struct AttemptCard: View {
} }
} }
.padding() .padding()
.background(.regularMaterial) .background(
.cornerRadius(12) RoundedRectangle(cornerRadius: 12)
.shadow(radius: 2) .fill(Color(uiColor: .secondarySystemGroupedBackground)) // Better contrast in light mode
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
)
} }
} }

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct GymsView: View { struct GymsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingAddGym = false @State private var showingAddGym = false
var body: some View { var body: some View {
@@ -19,7 +20,7 @@ struct GymsView: View {
if dataManager.isSyncing { if dataManager.isSyncing {
HStack(spacing: 2) { HStack(spacing: 2) {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue)) .progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.scaleEffect(0.6) .scaleEffect(0.6)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -48,6 +49,7 @@ struct GymsView: View {
struct GymsList: View { struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var gymToDelete: Gym? @State private var gymToDelete: Gym?
@State private var gymToEdit: Gym? @State private var gymToEdit: Gym?
@@ -71,7 +73,7 @@ struct GymsList: View {
Text("Edit") Text("Edit")
} }
} }
.tint(.blue) .tint(themeManager.accentColor)
} }
} }
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
@@ -98,6 +100,7 @@ struct GymsList: View {
struct GymRow: View { struct GymRow: View {
let gym: Gym let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
private var problemCount: Int { private var problemCount: Int {
dataManager.problems(forGym: gym.id).count dataManager.problems(forGym: gym.id).count
@@ -133,9 +136,9 @@ struct GymRow: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }

View File

@@ -2,15 +2,22 @@ import SwiftUI
struct ProblemsView: View { struct ProblemsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingAddProblem = false @State private var showingAddProblem = false
@State private var selectedClimbType: ClimbType? @State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym? @State private var selectedGym: Gym?
@State private var searchText = "" @State private var searchText = ""
@State private var showingSearch = false @State private var showingSearch = false
@State private var showingFilters = false
@FocusState private var isSearchFocused: Bool @FocusState private var isSearchFocused: Bool
@State private var cachedFilteredProblems: [Problem] = [] @State private var cachedFilteredProblems: [Problem] = []
// State moved from ProblemsList
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
private func updateFilteredProblems() { private func updateFilteredProblems() {
Task(priority: .userInitiated) { Task(priority: .userInitiated) {
let result = await computeFilteredProblems() let result = await computeFilteredProblems()
@@ -70,61 +77,67 @@ struct ProblemsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
VStack(spacing: 0) { if cachedFilteredProblems.isEmpty {
if showingSearch { VStack(spacing: 0) {
HStack(spacing: 8) { headerContent
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
TextField("Search problems...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 16))
.focused($isSearchFocused)
.submitLabel(.search)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if #available(iOS 18.0, *) {
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 0.5)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
if !dataManager.problems.isEmpty && !showingSearch {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.padding()
.background(.regularMaterial)
}
if cachedFilteredProblems.isEmpty {
EmptyProblemsView( EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty, isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty isFiltered: !dataManager.problems.isEmpty
) )
} else {
ProblemsList(problems: cachedFilteredProblems)
} }
} else {
List {
if showingSearch {
Section {
headerContent
}
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(cachedFilteredProblems) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
problemToDelete = problem
} label: {
Label("Delete", systemImage: "trash")
}
Button {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
}
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button {
problemToEdit = problem
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(themeManager.accentColor)
}
}
}
.listStyle(.plain)
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
} }
} }
.navigationTitle("Problems") .navigationTitle("Problems")
@@ -134,7 +147,7 @@ struct ProblemsView: View {
if dataManager.isSyncing { if dataManager.isSyncing {
HStack(spacing: 2) { HStack(spacing: 2) {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue)) .progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.scaleEffect(0.6) .scaleEffect(0.6)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -162,7 +175,15 @@ struct ProblemsView: View {
}) { }) {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass") Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundColor(showingSearch ? .secondary : .blue) .foregroundColor(showingSearch ? .secondary : themeManager.accentColor)
}
Button(action: {
showingFilters = true
}) {
Image(systemName: (selectedClimbType != nil || selectedGym != nil) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(themeManager.accentColor)
} }
if !dataManager.gyms.isEmpty { if !dataManager.gyms.isEmpty {
@@ -175,6 +196,32 @@ struct ProblemsView: View {
.sheet(isPresented: $showingAddProblem) { .sheet(isPresented: $showingAddProblem) {
AddEditProblemView() AddEditProblemView()
} }
.sheet(isPresented: $showingFilters) {
FilterSheet(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.presentationDetents([.height(320)])
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
} }
.onAppear { .onAppear {
updateFilteredProblems() updateFilteredProblems()
@@ -191,11 +238,57 @@ struct ProblemsView: View {
.onChange(of: selectedGym) { .onChange(of: selectedGym) {
updateFilteredProblems() updateFilteredProblems()
} }
.onChange(of: cachedFilteredProblems) {
animationKey += 1
}
}
@ViewBuilder
private var headerContent: some View {
VStack(spacing: 0) {
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
TextField("Search problems...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 16))
.focused($isSearchFocused)
.submitLabel(.search)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if #available(iOS 18.0, *) {
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 0.5)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
}
} }
} }
struct FilterSection: View { struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Binding var selectedClimbType: ClimbType? @Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym? @Binding var selectedGym: Gym?
let filteredProblems: [Problem] let filteredProblems: [Problem]
@@ -278,6 +371,7 @@ struct FilterChip: View {
let title: String let title: String
let isSelected: Bool let isSelected: Bool
let action: () -> Void let action: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
@@ -288,93 +382,21 @@ struct FilterChip: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background( .background(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? .blue : .clear) .fill(isSelected ? themeManager.accentColor : .clear)
.stroke(.blue, lineWidth: 1) .stroke(themeManager.accentColor, lineWidth: 1)
) )
.foregroundColor(isSelected ? .white : .blue) .foregroundColor(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
struct ProblemsList: View {
let problems: [Problem]
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View {
List(problems, id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
problemToDelete = problem
} label: {
Label("Delete", systemImage: "trash")
}
Button {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
}
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button {
problemToEdit = problem
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(.blue)
}
}
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
.clipped()
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
}
}
struct ProblemRow: View { struct ProblemRow: View {
let problem: Problem let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
private var gym: Gym? { private var gym: Gym? {
dataManager.gym(withId: problem.gymId) dataManager.gym(withId: problem.gymId)
@@ -407,7 +429,7 @@ struct ProblemRow: View {
if !problem.imagePaths.isEmpty { if !problem.imagePaths.isEmpty {
Image(systemName: "photo") Image(systemName: "photo")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
if isCompleted { if isCompleted {
@@ -419,7 +441,7 @@ struct ProblemRow: View {
Text(problem.difficulty.grade) Text(problem.difficulty.grade)
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
Text(problem.climbType.displayName) Text(problem.climbType.displayName)
@@ -444,9 +466,9 @@ struct ProblemRow: View {
.padding(.vertical, 2) .padding(.vertical, 2)
.background( .background(
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }
@@ -523,6 +545,71 @@ struct EmptyProblemsView: View {
} }
} }
struct FilterSheet: View {
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
@Environment(\.dismiss) var dismiss
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
NavigationStack {
ScrollView {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
)
.padding()
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
dismiss()
}) {
Text("Done")
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
)
.foregroundColor(themeManager.accentColor)
}
}
ToolbarItem(placement: .navigationBarLeading) {
if selectedClimbType != nil || selectedGym != nil {
Button(action: {
selectedClimbType = nil
selectedGym = nil
}) {
Text("Reset")
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
)
.foregroundColor(.red)
}
}
}
}
}
}
}
#Preview { #Preview {
ProblemsView() ProblemsView()
.environmentObject(ClimbingDataManager.preview) .environmentObject(ClimbingDataManager.preview)

View File

@@ -9,6 +9,7 @@ enum SheetType {
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var activeSheet: SheetType? @State private var activeSheet: SheetType?
var body: some View { var body: some View {
@@ -20,6 +21,8 @@ struct SettingsView: View {
HealthKitSection() HealthKitSection()
.environmentObject(dataManager.healthKitService) .environmentObject(dataManager.healthKitService)
AppearanceSection()
DataManagementSection( DataManagementSection(
activeSheet: $activeSheet activeSheet: $activeSheet
) )
@@ -75,8 +78,90 @@ extension SheetType: Identifiable {
} }
} }
struct AppearanceSection: View {
@EnvironmentObject var themeManager: ThemeManager
let columns = [
GridItem(.adaptive(minimum: 44))
]
var body: some View {
Section("Appearance") {
VStack(alignment: .leading, spacing: 12) {
Text("Accent Color")
.font(.caption)
.foregroundColor(.secondary)
.textCase(.uppercase)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(ThemeManager.presetColors, id: \.self) { color in
Circle()
.fill(color)
.frame(width: 44, height: 44)
.overlay(
ZStack {
if isSelected(color) {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.white)
.shadow(radius: 1)
}
}
)
.onTapGesture {
withAnimation {
themeManager.accentColor = color
}
}
.accessibilityLabel(colorDescription(for: color))
.accessibilityAddTraits(isSelected(color) ? .isSelected : [])
}
}
.padding(.vertical, 8)
}
if !isSelected(.blue) {
Button("Reset to Default") {
withAnimation {
themeManager.resetToDefault()
}
}
.foregroundColor(.red)
}
}
}
private func isSelected(_ color: Color) -> Bool {
// Compare using UIColor to handle different Color initializers
let selectedUIColor = UIColor(themeManager.accentColor)
let targetUIColor = UIColor(color)
// Simple equality check might fail for some system colors, so we check components if needed
// But usually UIColor equality is robust enough for system colors
return selectedUIColor == targetUIColor
}
private func colorDescription(for color: Color) -> String {
switch color {
case .blue: return "Blue"
case .purple: return "Purple"
case .pink: return "Pink"
case .red: return "Red"
case .orange: return "Orange"
case .green: return "Green"
case .teal: return "Teal"
case .indigo: return "Indigo"
case .mint: return "Mint"
case Color(uiColor: .systemBrown): return "Brown"
case Color(uiColor: .systemCyan): return "Cyan"
default: return "Color"
}
}
}
struct DataManagementSection: View { struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Binding var activeSheet: SheetType? @Binding var activeSheet: SheetType?
@State private var showingResetAlert = false @State private var showingResetAlert = false
@State private var isExporting = false @State private var isExporting = false
@@ -100,7 +185,7 @@ struct DataManagementSection: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else { } else {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Export Data") Text("Export Data")
} }
Spacer() Spacer()
@@ -253,6 +338,7 @@ struct DataManagementSection: View {
} }
struct AppInfoSection: View { struct AppInfoSection: View {
@EnvironmentObject var themeManager: ThemeManager
private var appVersion: String { private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
} }
@@ -265,7 +351,7 @@ struct AppInfoSection: View {
Section("App Information") { Section("App Information") {
HStack { HStack {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Version") Text("Version")
Spacer() Spacer()
Text("\(appVersion) (\(buildNumber))") Text("\(appVersion) (\(buildNumber))")
@@ -278,6 +364,7 @@ struct AppInfoSection: View {
struct ExportDataView: View { struct ExportDataView: View {
let data: Data let data: Data
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var themeManager: ThemeManager
@State private var tempFileURL: URL? @State private var tempFileURL: URL?
@State private var isCreatingFile = true @State private var isCreatingFile = true
@@ -291,7 +378,7 @@ struct ExportDataView: View {
VStack(spacing: 20) { VStack(spacing: 20) {
ProgressView() ProgressView()
.scaleEffect(1.5) .scaleEffect(1.5)
.tint(.blue) .tint(themeManager.accentColor)
Text("Preparing Your Export") Text("Preparing Your Export")
.font(.title2) .font(.title2)
@@ -330,12 +417,12 @@ struct ExportDataView: View {
) { ) {
Label("Share Data", systemImage: "square.and.arrow.up") Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(themeManager.contrastingTextColor)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(.blue) .fill(themeManager.accentColor)
) )
} }
.padding(.horizontal) .padding(.horizontal)
@@ -430,6 +517,7 @@ struct ExportDataView: View {
struct SyncSection: View { struct SyncSection: View {
@EnvironmentObject var syncService: SyncService @EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingSyncSettings = false @State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false @State private var showingDisconnectAlert = false
@@ -475,7 +563,7 @@ struct SyncSection: View {
}) { }) {
HStack { HStack {
Image(systemName: "gear") Image(systemName: "gear")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Configure Server") Text("Configure Server")
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
@@ -594,6 +682,7 @@ struct SyncSection: View {
struct SyncSettingsView: View { struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService @EnvironmentObject var syncService: SyncService
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var serverURL: String = "" @State private var serverURL: String = ""
@State private var authToken: String = "" @State private var authToken: String = ""
@@ -644,7 +733,7 @@ struct SyncSettingsView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else { } else {
Image(systemName: "network") Image(systemName: "network")
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
Text("Test Connection") Text("Test Connection")
Spacer() Spacer()
if syncService.isConnected { if syncService.isConnected {
@@ -705,6 +794,12 @@ struct SyncSettingsView: View {
syncService.serverURL = newURL syncService.serverURL = newURL
syncService.authToken = newToken syncService.authToken = newToken
// Ensure provider type is set to server
if syncService.providerType != .server {
syncService.providerType = .server
}
dismiss() dismiss()
} }
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -745,6 +840,13 @@ struct SyncSettingsView: View {
Task { Task {
do { do {
// Ensure we are using the server provider
await MainActor.run {
if syncService.providerType != .server {
syncService.providerType = .server
}
}
// Temporarily set the values for testing // Temporarily set the values for testing
syncService.serverURL = testURL syncService.serverURL = testURL
syncService.authToken = testToken syncService.authToken = testToken