Compare commits

...

11 Commits

Author SHA1 Message Date
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
a212f3f3b5 2.3.0 - Unified logging and app intents
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
2025-11-20 21:00:00 -07:00
a99196b9ca Deps for docs 2025-11-19 15:04:47 -07: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
41 changed files with 3049 additions and 2487 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 = 46 versionCode = 47
versionName = "2.2.1" versionName = "2.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -38,7 +38,10 @@ android {
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true } buildFeatures {
compose = true
buildConfig = true
}
} }
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }

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,6 +212,54 @@ class SessionTrackingService : Service() {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
} }
val notificationBuilder =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
// 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 = val duration =
session.startTime?.let { startTime -> session.startTime?.let { startTime ->
try { try {
@@ -230,30 +281,14 @@ class SessionTrackingService : Service() {
} }
?: "Active" ?: "Active"
val notification = notificationBuilder
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active") .setContentTitle("Climbing Session Active")
.setContentText( .setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts" "${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
) )
.setSmallIcon(R.drawable.ic_mountains) }
.setOngoing(true)
.setAutoCancel(false) val notification = notificationBuilder.build()
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
.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,
activeSessionGym = activeSessionGym,
selectedMonth = selectedMonth, 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,13 +322,23 @@ 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)
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
}
val numRows = totalCells / 7
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -346,7 +347,8 @@ fun CalendarView(
) )
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), modifier =
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row( Row(
@@ -410,19 +412,15 @@ fun CalendarView(
} }
Spacer(modifier = Modifier.height(8.dp)) 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(numRows) { rowIndex ->
items(totalCells) { index -> Row(modifier = Modifier.fillMaxWidth()) {
for (colIndex in 0 until 7) {
val index = rowIndex * 7 + colIndex
val dayNumber = index - firstDayOfWeek + 1 val dayNumber = index - firstDayOfWeek + 1
Box(modifier = Modifier.weight(1f)) {
if (dayNumber in 1..daysInMonth) { if (dayNumber in 1..daysInMonth) {
val date = selectedMonth.atDay(dayNumber) val date = selectedMonth.atDay(dayNumber)
val sessionsOnDate = sessionsByDate[date] ?: emptyList() val sessionsOnDate = sessionsByDate[date] ?: emptyList()
@@ -445,10 +443,13 @@ fun CalendarView(
} }
} }
} }
}
}
if (selectedDate != null) { if (selectedDate != null) {
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList() val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@@ -458,8 +459,8 @@ fun CalendarView(
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 -> items(sessionsOnSelectedDate) { session ->
SessionCard( SessionCard(
session = session, session = session,
@@ -468,7 +469,8 @@ fun CalendarView(
) )
Spacer(modifier = Modifier.height(8.dp)) 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

@@ -3,11 +3,10 @@ package com.atridad.ascently.utils
import android.util.Log import android.util.Log
import com.atridad.ascently.BuildConfig import com.atridad.ascently.BuildConfig
/**
* Centralized logging utility to ensure all mobile logging happens only in debug builds.
*/
object AppLogger { object AppLogger {
private const val DEFAULT_TAG = "Ascently"
enum class Level(val androidLevel: Int) { enum class Level(val androidLevel: Int) {
DEBUG(Log.DEBUG), DEBUG(Log.DEBUG),
INFO(Log.INFO), INFO(Log.INFO),
@@ -46,6 +45,4 @@ object AppLogger {
Log.println(level.androidLevel, tag, message) Log.println(level.androidLevel, tag, message)
} }
} }
private const val DEFAULT_TAG = "Ascently"
} }

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",
@@ -25,13 +25,13 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.0", "@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.36.1", "@astrojs/starlight": "^0.37.0",
"astro": "^5.14.6", "astro": "^5.16.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.4" "sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5" "@types/qrcode": "^1.5.6"
} }
} }

1182
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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 = 31; CURRENT_PROJECT_VERSION = 34;
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.2.1; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 34;
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.2.1; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 34;
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.2.1; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31; CURRENT_PROJECT_VERSION = 34;
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.2.1; MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,24 @@
import AppIntents
/// Defines the App Shortcuts available in the Shortcuts app.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
.teal
}
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: ToggleSessionIntent(),
phrases: [
"Toggle climb in \(.applicationName)",
"Start or stop climb in \(.applicationName)",
"Climb toggle in \(.applicationName)",
],
shortTitle: "Toggle Session",
systemImageName: "figure.climbing"
)
]
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
/// User-visible errors that can arise while handling session-related intents.
enum SessionIntentError: LocalizedError {
case noRecentGym
case noActiveSession
case failedToStartSession
case failedToEndSession
var errorDescription: String? {
switch self {
case .noRecentGym:
return "There's no recent gym to start a session with."
case .noActiveSession:
return "There isn't an active session to end right now."
case .failedToStartSession:
return "Ascently couldn't start a new session."
case .failedToEndSession:
return "Ascently couldn't finish the active session."
}
}
}
struct SessionIntentSummary: Sendable {
let sessionId: UUID
let gymName: String
let status: SessionStatus
}
/// Controller for handling session operations from App Intents.
@MainActor
final class SessionIntentController {
private let dataManager: ClimbingDataManager
init(dataManager: ClimbingDataManager = .shared) {
self.dataManager = dataManager
}
/// Starts a new session using the most recently visited gym.
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
}
guard let lastGym = dataManager.getLastUsedGym() else {
logFailure(.noRecentGym, context: "No recorded sessions available")
throw SessionIntentError.noRecentGym
}
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession
}
return SessionIntentSummary(
sessionId: startedSession.id,
gymName: lastGym.name,
status: startedSession.status
)
}
/// Ends the currently active climbing session, if one exists.
func endActiveSession() async throws -> SessionIntentSummary {
guard let activeSession = dataManager.activeSession else {
logFailure(.noActiveSession, context: "No active session stored in data manager")
throw SessionIntentError.noActiveSession
}
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
logFailure(
.failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession
}
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
logFailure(
.failedToEndSession,
context: "Gym missing for completed session \(completedSession.id)")
throw SessionIntentError.failedToEndSession
}
return SessionIntentSummary(
sessionId: completedSession.id,
gymName: gym.name,
status: completedSession.status
)
}
private func logFailure(_ error: SessionIntentError, context: String) {
// Log error for debugging
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

@@ -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

@@ -1,10 +1,25 @@
import SwiftUI import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
return true
}
}
@main @main
struct AscentlyApp: App { struct AscentlyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@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()

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager() @StateObject private var dataManager = ClimbingDataManager.shared
@State private var selectedTab = 0 @State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = [] @State private var notificationObservers: [NSObjectProtocol] = []

View File

@@ -6,6 +6,7 @@
<true/> <true/>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string> <string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>

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

@@ -15,6 +15,8 @@ import UniformTypeIdentifiers
@MainActor @MainActor
class ClimbingDataManager: ObservableObject { class ClimbingDataManager: ObservableObject {
static let shared = ClimbingDataManager()
@Published var gyms: [Gym] = [] @Published var gyms: [Gym] = []
@Published var problems: [Problem] = [] @Published var problems: [Problem] = []
@Published var sessions: [ClimbSession] = [] @Published var sessions: [ClimbSession] = []
@@ -78,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
let name: String let name: String
} }
init() { fileprivate init() {
_ = ImageManager.shared _ = ImageManager.shared
migrateFromOpenClimbIfNeeded() migrateFromOpenClimbIfNeeded()
loadAllData() loadAllData()
@@ -415,9 +417,16 @@ class ClimbingDataManager: ObservableObject {
} }
func startSession(gymId: UUID, notes: String? = nil) { func startSession(gymId: UUID, notes: String? = nil) {
// End any currently active session Task { @MainActor in
await startSessionAsync(gymId: gymId, notes: notes)
}
}
@discardableResult
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
// End any currently active session before starting a new one
if let currentActive = activeSession { if let currentActive = activeSession {
endSession(currentActive.id) await endSessionAsync(currentActive.id)
} }
let newSession = ClimbSession(gymId: gymId, notes: notes) let newSession = ClimbSession(gymId: gymId, notes: notes)
@@ -430,14 +439,12 @@ class ClimbingDataManager: ObservableObject {
// MARK: - Start Live Activity for new session // MARK: - Start Live Activity for new session
if let gym = gym(withId: gymId) { if let gym = gym(withId: gymId) {
Task {
await LiveActivityManager.shared.startLiveActivity( await LiveActivityManager.shared.startLiveActivity(
for: newSession, gymName: gym.name) for: newSession,
} gymName: gym.name)
} }
if healthKitService.isEnabled { if healthKitService.isEnabled {
Task {
do { do {
try await healthKitService.startWorkout( try await healthKitService.startWorkout(
startDate: newSession.startTime ?? Date(), startDate: newSession.startTime ?? Date(),
@@ -448,13 +455,24 @@ class ClimbingDataManager: ObservableObject {
tag: LogTag.climbingData) tag: LogTag.climbingData)
} }
} }
}
return newSession
} }
func endSession(_ sessionId: UUID) { func endSession(_ sessionId: UUID) {
if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), Task { @MainActor in
await endSessionAsync(sessionId)
}
}
@discardableResult
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? {
guard
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId }) let index = sessions.firstIndex(where: { $0.id == sessionId })
{ else {
return nil
}
let completedSession = session.completed() let completedSession = session.completed()
sessions[index] = completedSession sessions[index] = completedSession
@@ -471,12 +489,9 @@ class ClimbingDataManager: ObservableObject {
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
// MARK: - End Live Activity after session ends // MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
}
if healthKitService.isEnabled { if healthKitService.isEnabled {
Task {
do { do {
try await healthKitService.endWorkout( try await healthKitService.endWorkout(
endDate: completedSession.endTime ?? Date()) endDate: completedSession.endTime ?? Date())
@@ -486,8 +501,8 @@ class ClimbingDataManager: ObservableObject {
tag: LogTag.climbingData) tag: LogTag.climbingData)
} }
} }
}
} return completedSession
} }
func updateSession(_ session: ClimbSession) { func updateSession(_ session: ClimbSession) {

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())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
AttemptsSection( Section {
attemptsWithProblems: attemptsWithProblems, if attemptsWithProblems.isEmpty {
attemptToDelete: $attemptToDelete, VStack(spacing: 12) {
editingAttempt: $editingAttempt) 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 { } else {
Text("Session not found") Text("Session not found")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.padding() .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,6 +2,7 @@ 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?
@@ -134,7 +135,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 +163,7 @@ 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)
} }
if !dataManager.gyms.isEmpty { if !dataManager.gyms.isEmpty {
@@ -196,6 +197,7 @@ struct ProblemsView: View {
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 +280,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,10 +291,10 @@ 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)
} }
@@ -300,6 +303,7 @@ struct FilterChip: View {
struct ProblemsList: View { struct ProblemsList: View {
let problems: [Problem] let problems: [Problem]
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var problemToDelete: Problem? @State private var problemToDelete: Problem?
@State private var problemToEdit: Problem? @State private var problemToEdit: Problem?
@State private var animationKey = 0 @State private var animationKey = 0
@@ -337,7 +341,7 @@ struct ProblemsList: View {
Text("Edit") Text("Edit")
} }
} }
.tint(.blue) .tint(themeManager.accentColor)
} }
} }
.animation( .animation(
@@ -375,6 +379,7 @@ struct ProblemsList: View {
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 +412,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 +424,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 +449,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)
} }
} }
} }

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

View File

@@ -8,7 +8,6 @@ import WidgetKit
struct SessionStatusLiveBundle: WidgetBundle { struct SessionStatusLiveBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
SessionStatusLive() SessionStatusLive()
SessionStatusLiveControl()
SessionStatusLiveLiveActivity() SessionStatusLiveLiveActivity()
} }
} }

View File

@@ -1,74 +0,0 @@
//
// SessionStatusLiveControl.swift
import AppIntents
import SwiftUI
import WidgetKit
struct SessionStatusLiveControl: ControlWidget {
static let kind: String = "com.atridad.Ascently.SessionStatusLive"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension SessionStatusLiveControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(
isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -13,7 +13,7 @@ import (
"time" "time"
) )
const VERSION = "2.2.0" const VERSION = "2.3.0"
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {