Compare commits

..

21 Commits

Author SHA1 Message Date
50b30442e8 oops 2025-12-03 15:45:05 -07:00
b365b967b2 Android 2.4.0 - Backend changes :) 2025-12-03 15:41:45 -07:00
cacd178817 iOS 2.4.1 - Minor Visual Tweaks 2025-12-03 00:10:08 -07:00
922412c2c2 Bumped build 2025-12-02 17:09:18 -07:00
acb1b1f532 2.4.0 - Updated Sync Architecture (Provider pattern) 2025-12-02 17:07:52 -07:00
c8694eacab iOS 2.4.0 - Colour accents and theming 2025-12-02 15:55:48 -07:00
57855b8332 Docs updates
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 5m14s
2025-12-01 17:07:30 -07:00
6342bfed5c 2.3.1 - Dependency Updates, Better Live Notifications, and Calendar Fixes 2025-12-01 11:46:31 -07:00
869ca0fc0d Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s
Reviewed-on: #6
2025-11-21 04:01:43 +00:00
33562e9d16 Merge branch 'main' into logging
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 7m42s
2025-11-21 04:01:16 +00:00
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
93fb7a41fb iOS 2.2.1 2025-11-18 12:59:26 -07:00
6d67ae6d81 Logging overhaul 2025-11-18 12:58:45 -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
c2f95f2793 [Android] 2.2.1 - Better Widget 2025-10-21 10:22:31 -06:00
b7a3c98b2c [Android] 2.2.1 - Better Widget 2025-10-21 10:21:35 -06:00
fed9bab2ea Fixeds QR flashing
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m49s
2025-10-21 08:50:34 -06:00
862622b07b Fixed
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2025-10-20 00:03:35 -06:00
eba503eb5e Updated docs with QR Codes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m26s
2025-10-19 23:55:30 -06:00
73 changed files with 5859 additions and 4500 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 = 45 versionCode = 48
versionName = "2.2.0" versionName = "2.4.0"
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

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController import androidx.health.connect.client.PermissionController
@@ -60,7 +60,7 @@ class HealthConnectManager(private val context: Context) {
try { try {
HealthConnectClient.getOrCreate(context) HealthConnectClient.getOrCreate(context)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e) AppLogger.e(TAG, e) { "Failed to create Health Connect client" }
_isCompatible.value = false _isCompatible.value = false
null null
} }
@@ -75,7 +75,7 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context) val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE) emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e) AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false _isCompatible.value = false
emit(false) emit(false)
} }
@@ -90,10 +90,10 @@ class HealthConnectManager(private val context: Context) {
try { try {
val alreadyHasPermissions = hasAllPermissions() val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) { if (!alreadyHasPermissions) {
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI") AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error checking permissions when enabling Health Connect", e) AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" }
} }
} else if (!enabled) { } else if (!enabled) {
setPermissionsGranted(false) setPermissionsGranted(false)
@@ -119,7 +119,7 @@ class HealthConnectManager(private val context: Context) {
setPermissionsGranted(hasAll) setPermissionsGranted(hasAll)
hasAll hasAll
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e) AppLogger.e(TAG, e) { "Error checking permissions" }
setPermissionsGranted(false) setPermissionsGranted(false)
false false
} }
@@ -135,7 +135,7 @@ class HealthConnectManager(private val context: Context) {
val hasPerms = if (isAvailable) hasAllPermissions() else false val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms isAvailable && hasPerms
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e) AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false false
} }
} }
@@ -148,7 +148,7 @@ class HealthConnectManager(private val context: Context) {
return try { return try {
REQUIRED_PERMISSIONS.map { it }.toSet() REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e) AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet() emptySet()
} }
} }
@@ -181,7 +181,7 @@ class HealthConnectManager(private val context: Context) {
) )
} }
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...") AppLogger.d(TAG) { "Attempting to sync session '${session.id}' to Health Connect..." }
val records = mutableListOf<androidx.health.connect.client.records.Record>() val records = mutableListOf<androidx.health.connect.client.records.Record>()
@@ -199,7 +199,7 @@ class HealthConnectManager(private val context: Context) {
) )
records.add(exerciseSession) records.add(exerciseSession)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e) AppLogger.w(TAG, e) { "Failed to create exercise session record" }
} }
try { try {
@@ -220,23 +220,22 @@ class HealthConnectManager(private val context: Context) {
records.add(caloriesRecord) records.add(caloriesRecord)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e) AppLogger.w(TAG, e) { "Failed to create calories record" }
} }
try { try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount) val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) } heartRateRecord?.let { records.add(it) }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e) AppLogger.w(TAG, e) { "Failed to create heart rate record" }
} }
if (records.isNotEmpty() && healthConnectClient != null) { if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...") AppLogger.d(TAG) { "Writing ${records.size} records to Health Connect..." }
healthConnectClient!!.insertRecords(records) healthConnectClient!!.insertRecords(records)
Log.i( AppLogger.i(TAG) {
TAG,
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect" "Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
) }
preferences preferences
.edit() .edit()
@@ -249,13 +248,13 @@ class HealthConnectManager(private val context: Context) {
healthConnectClient == null -> "Health Connect client unavailable" healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason" else -> "Unknown reason"
} }
Log.w(TAG, "Sync failed for session '${session.id}': $reason") AppLogger.w(TAG) { "Sync failed for session '${session.id}': $reason" }
return Result.failure(Exception("Sync failed: $reason")) return Result.failure(Exception("Sync failed: $reason"))
} }
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e) AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" }
Result.failure(e) Result.failure(e)
} }
} }
@@ -266,7 +265,7 @@ class HealthConnectManager(private val context: Context) {
attemptCount: Int = 0 attemptCount: Int = 0
): Result<Unit> { ): Result<Unit> {
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) { return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...") AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
syncCompletedSession(session, gymName, attemptCount) syncCompletedSession(session, gymName, attemptCount)
} else { } else {
val reason = val reason =
@@ -276,7 +275,7 @@ class HealthConnectManager(private val context: Context) {
!isReady() -> "Health Connect not ready" !isReady() -> "Health Connect not ready"
else -> "unknown reason" else -> "unknown reason"
} }
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason") AppLogger.d(TAG) { "Auto-sync skipped for session '${session.id}': $reason" }
Result.success(Unit) Result.success(Unit)
} }
} }
@@ -328,7 +327,7 @@ class HealthConnectManager(private val context: Context) {
samples = samples samples = samples
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e) AppLogger.e(TAG, e) { "Error creating heart rate record" }
null null
} }
} }

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.* import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun updateGym(gym: Gym) { suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym) gymDao.updateGym(gym)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun deleteGym(gym: Gym) { suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym) gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym") trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateProblem(problem: Problem) { suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem) problemDao.updateProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteProblem(problem: Problem) { suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem) problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem") trackDeletion(problem.id, "problem")
@@ -78,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> = fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId) sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow() fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) { suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun updateSession(session: ClimbSession) { suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session) sessionDao.updateSession(session)
dataStateManager.updateDataState() dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync() triggerAutoSync()
} }
} }
suspend fun deleteSession(session: ClimbSession) { suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session) sessionDao.deleteSession(session)
trackDeletion(session.id, "session") trackDeletion(session.id, "session")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
suspend fun getLastUsedGym(): Gym? { suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first() val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) { return if (recentSessions.isNotEmpty()) {
@@ -115,16 +124,20 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts() fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> = fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId) attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> = fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId) attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) { suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt) attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun updateAttempt(attempt: Attempt) { suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt) attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
suspend fun deleteAttempt(attempt: Attempt) { suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt) attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt") trackDeletion(attempt.id, "attempt")
@@ -386,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) { if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0 val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively() imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" }
} }
} }
} }

View File

@@ -2,8 +2,8 @@ package com.atridad.ascently.data.state
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils import com.atridad.ascently.utils.DateFormatUtils
/** /**
@@ -26,7 +26,7 @@ class DataStateManager(context: Context) {
if (!isInitialized()) { if (!isInitialized()) {
updateDataState() updateDataState()
markAsInitialized() markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}") AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" }
} }
} }
@@ -37,7 +37,7 @@ class DataStateManager(context: Context) {
fun updateDataState() { fun updateDataState() {
val now = DateFormatUtils.nowISO8601() val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) } prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now") AppLogger.d(TAG) { "Data state updated to: $now" }
} }
/** /**

View File

@@ -0,0 +1,741 @@
package com.atridad.ascently.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class AscentlySyncProvider(
private val context: Context,
private val repository: ClimbRepository
) : SyncProvider {
override val type: SyncProviderType = SyncProviderType.SERVER
private val dataStateManager = DataStateManager(context)
companion object {
private const val TAG = "AscentlySyncProvider"
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
override val isConfigured: StateFlow<Boolean> = _isConfigured.asStateFlow()
private var isOfflineMode = false
private object Keys {
const val SERVER_URL = "server_url"
const val AUTH_TOKEN = "auth_token"
const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time"
const val OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
}
private fun loadInitialState() {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
}
private fun updateConfiguredState() {
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
}
var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
override suspend fun sync() {
if (isOfflineMode) {
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return
}
if (!isNetworkAvailable()) {
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
throw SyncException.NetworkError("No internet connection.")
}
if (!_isConfigured.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
val localBackup = createBackupFromRepository()
val serverBackup = downloadData()
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
val hasServerData =
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
AppLogger.d(TAG) { "Full restore completed" }
}
hasLocalData && !hasServerData -> {
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
uploadData(localBackup)
syncImagesForBackup(localBackup)
AppLogger.d(TAG) { "Initial upload completed" }
}
hasLocalData && hasServerData -> {
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
mergeDataSafely(serverBackup)
AppLogger.d(TAG) { "Merge completed" }
}
else -> {
AppLogger.d(TAG) { "No data to sync" }
}
}
}
val now = DateFormatUtils.nowISO8601()
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
}
override fun disconnect() {
serverUrl = ""
authToken = ""
_isConnected.value = false
sharedPreferences.edit {
remove(Keys.LAST_SYNC_TIME)
putBoolean(Keys.IS_CONNECTED, false)
}
updateConfiguredState()
}
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
AppLogger.d(TAG) {
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
}
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
AppLogger.d(TAG) {
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
}
applyDeltaResponse(deltaResponse)
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (_: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// SyncService handles the "isSyncing" state to prevent recursive sync triggers
// when the repository is modified during a sync operation.
try {
// Merge and apply deletions first to prevent resurrection
val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" }
applyDeletions(uniqueDeletions)
// Build deleted item lookup set
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
if (deletedItemSet.contains("problem:${problem.id}")) {
continue
}
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
// Merge gyms
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
if (deletedItemSet.contains("gym:${backupGym.id}")) {
continue
}
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
continue
}
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
if (deletedItemSet.contains("session:${backupSession.id}")) {
continue
}
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
continue
}
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
repository.clearDeletedItems()
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} catch (e: Exception) {
AppLogger.e(TAG, e) { "Error applying delta response" }
throw e
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.put(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
}
return imagePathMapping
}
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.bytes()?.let {
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
}
} else {
if (response.code == 404) throw SyncException.ImageNotFound
null
}
}
} catch (e: IOException) {
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
val filename = localPath.substringAfterLast('/')
uploadImage(localPath, filename)
}
}
}
}
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" }
return
}
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else {
AppLogger.w(TAG) {
"Failed to upload image $filename. Server responded with ${response.code}"
}
}
}
} catch (e: IOException) {
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
}
}
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) {
ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems =
repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions =
repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it)
},
attempts =
repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it)
},
deletedItems = repository.getDeletedItems()
)
}
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String>
) {
val gyms = backup.gyms.map { it.toGym() }
val problems =
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
gyms.forEach { repository.insertGymWithoutSync(it) }
problems.forEach { repository.insertProblemWithoutSync(it) }
sessions.forEach { repository.insertSessionWithoutSync(it) }
attempts.forEach { repository.insertAttemptWithoutSync(it) }
repository.clearDeletedItems()
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
private fun handleHttpError(code: Int): Nothing {
when (code) {
401 -> throw SyncException.Unauthorized
in 500..599 -> throw SyncException.ServerError(code)
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
override suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
throw SyncException.NotConfigured
}
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response ->
_isConnected.value = response.isSuccessful || response.code == 405
}
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
} catch (e: Exception) {
_isConnected.value = false
throw SyncException.NetworkError(e.message ?: "Connection error")
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
}
}
}

View File

@@ -0,0 +1,21 @@
package com.atridad.ascently.data.sync
import java.io.IOException
import java.io.Serializable
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -0,0 +1,18 @@
package com.atridad.ascently.data.sync
import kotlinx.coroutines.flow.StateFlow
interface SyncProvider {
val type: SyncProviderType
val isConfigured: StateFlow<Boolean>
val isConnected: StateFlow<Boolean>
suspend fun sync()
suspend fun testConnection()
fun disconnect()
}
enum class SyncProviderType {
NONE,
SERVER
}

View File

@@ -2,27 +2,9 @@ package com.atridad.ascently.data.sync
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.data.format.BackupClimbSession
import com.atridad.ascently.data.format.BackupGym
import com.atridad.ascently.data.format.BackupProblem
import com.atridad.ascently.data.format.ClimbDataBackup
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.ImageNamingUtils
import com.atridad.ascently.utils.ImageUtils
import java.io.IOException
import java.io.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -31,43 +13,21 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class SyncService(private val context: Context, private val repository: ClimbRepository) { class SyncService(private val context: Context, private val repository: ClimbRepository) {
private val dataStateManager = DataStateManager(context)
private val syncMutex = Mutex()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val syncMutex = Mutex()
companion object { companion object {
private const val TAG = "SyncService" private const val TAG = "SyncService"
} }
private val sharedPreferences: SharedPreferences = // Currently we only support one provider, but this allows for future expansion
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) private val provider: SyncProvider = AscentlySyncProvider(context, repository)
private val httpClient =
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
// State // State
private val _isSyncing = MutableStateFlow(false) private val _isSyncing = MutableStateFlow(false)
@@ -79,11 +39,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _syncError = MutableStateFlow<String?>(null) private val _syncError = MutableStateFlow<String?>(null)
val syncError: StateFlow<String?> = _syncError.asStateFlow() val syncError: StateFlow<String?> = _syncError.asStateFlow()
private val _isConnected = MutableStateFlow(false) // Delegate to provider
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = provider.isConnected
val isConfiguredFlow: StateFlow<Boolean> = provider.isConfigured
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
private val _isTesting = MutableStateFlow(false) private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow() val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
@@ -91,56 +49,40 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isAutoSyncEnabled = MutableStateFlow(true) private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow() val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
private var isOfflineMode = false
// Debounced sync properties // Debounced sync properties
private var syncJob: Job? = null private var syncJob: Job? = null
private var pendingChanges = false private var pendingChanges = false
private val syncDebounceDelay = 2000L // 2 seconds private val syncDebounceDelay = 2000L // 2 seconds
// Configuration keys private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
private object Keys { private object Keys {
const val SERVER_URL = "server_url"
const val AUTH_TOKEN = "auth_token"
const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time" const val LAST_SYNC_TIME = "last_sync_time"
const val AUTO_SYNC_ENABLED = "auto_sync_enabled" const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
const val OFFLINE_MODE = "offline_mode"
} }
init { init {
loadInitialState() loadInitialState()
updateConfiguredState()
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } } repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
} }
private fun loadInitialState() { private fun loadInitialState() {
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) _isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
}
private fun updateConfiguredState() {
_isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
} }
// Proxy properties for Ascently provider configuration
var serverUrl: String var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" get() = (provider as? AscentlySyncProvider)?.serverUrl ?: ""
set(value) { set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) } (provider as? AscentlySyncProvider)?.serverUrl = value
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
} }
var authToken: String var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" get() = (provider as? AscentlySyncProvider)?.authToken ?: ""
set(value) { set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) } (provider as? AscentlySyncProvider)?.authToken = value
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
} }
fun setAutoSyncEnabled(enabled: Boolean) { fun setAutoSyncEnabled(enabled: Boolean) {
@@ -148,90 +90,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) } sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) }
} }
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
}
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() { suspend fun syncWithServer() {
if (isOfflineMode) { if (!isConfiguredFlow.value) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.")
return
}
if (!isNetworkAvailable()) {
_syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.")
return
}
if (!_isConfigured.value) {
throw SyncException.NotConfigured throw SyncException.NotConfigured
} }
if (!_isConnected.value) {
throw SyncException.NotConnected
}
syncMutex.withLock { syncMutex.withLock {
_isSyncing.value = true _isSyncing.value = true
_syncError.value = null _syncError.value = null
try { try {
val localBackup = createBackupFromRepository() provider.sync()
val serverBackup = downloadData()
val hasLocalData = // Update last sync time from shared prefs (provider updates it)
localBackup.gyms.isNotEmpty() || _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
val hasServerData =
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
// If both client and server have been synced before, use delta sync
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
Log.d(TAG, "Using delta sync for incremental updates")
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed")
}
hasLocalData && !hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)")
mergeDataSafely(serverBackup)
Log.d(TAG, "Merge completed")
}
else -> {
Log.d(TAG, "No data to sync")
}
}
}
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
} catch (e: Exception) { } catch (e: Exception) {
_syncError.value = e.message _syncError.value = e.message
throw e throw e
@@ -241,550 +114,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr")
// Parse last sync time to filter modified items
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
// Collect items modified since last sync
val allGyms = repository.getAllGyms().first()
val modifiedGyms =
allGyms
.filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true }
.map { BackupGym.fromGym(it) }
val allProblems = repository.getAllProblems().first()
val modifiedProblems =
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
val allSessions = repository.getAllSessions().first()
val modifiedSessions =
allSessions
.filter { session ->
parseISO8601(session.updatedAt)?.after(lastSyncDate) == true
}
.map { BackupClimbSession.fromClimbSession(it) }
val allAttempts = repository.getAllAttempts().first()
val modifiedAttempts =
allAttempts
.filter { attempt ->
parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true
}
.map { BackupAttempt.fromAttempt(it) }
val allDeletions = repository.getDeletedItems()
val modifiedDeletions =
allDeletions.filter { item ->
parseISO8601(item.deletedAt)?.after(lastSyncDate) == true
}
Log.d(
TAG,
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
)
// Create delta request
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
Log.d(
TAG,
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
)
// Apply server changes to local data
applyDeltaResponse(deltaResponse)
// Sync only modified problem images
syncModifiedImages(modifiedProblems)
}
private fun parseISO8601(dateString: String): Date? {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
format.parse(dateString)
} catch (e: Exception) {
null
}
}
private suspend fun applyDeltaResponse(response: DeltaSyncResponse) {
// Temporarily disable auto-sync to prevent recursive sync triggers
repository.setAutoSyncCallback(null)
try {
// Merge and apply deletions first to prevent resurrection
val allDeletions = repository.getDeletedItems() + response.deletedItems
val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" }
Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data")
applyDeletions(uniqueDeletions)
// Build deleted item lookup set
val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet()
// Download images for new/modified problems from server
val imagePathMapping = mutableMapOf<String, String>()
for (problem in response.problems) {
if (deletedItemSet.contains("problem:${problem.id}")) {
continue
}
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
// Merge gyms
val existingGyms = repository.getAllGyms().first()
for (backupGym in response.gyms) {
if (deletedItemSet.contains("gym:${backupGym.id}")) {
continue
}
val existing = existingGyms.find { it.id == backupGym.id }
if (existing == null || backupGym.updatedAt >= existing.updatedAt) {
val gym = backupGym.toGym()
if (existing != null) {
repository.updateGym(gym)
} else {
repository.insertGym(gym)
}
}
}
// Merge problems
val existingProblems = repository.getAllProblems().first()
for (backupProblem in response.problems) {
if (deletedItemSet.contains("problem:${backupProblem.id}")) {
continue
}
val updatedImagePaths =
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
val existing = existingProblems.find { it.id == backupProblem.id }
if (existing == null || backupProblem.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateProblem(problem)
} else {
repository.insertProblem(problem)
}
}
}
// Merge sessions
val existingSessions = repository.getAllSessions().first()
for (backupSession in response.sessions) {
if (deletedItemSet.contains("session:${backupSession.id}")) {
continue
}
val session = backupSession.toClimbSession()
val existing = existingSessions.find { it.id == backupSession.id }
if (existing == null || backupSession.updatedAt >= existing.updatedAt) {
if (existing != null) {
repository.updateSession(session)
} else {
repository.insertSession(session)
}
}
}
// Merge attempts
val existingAttempts = repository.getAllAttempts().first()
for (backupAttempt in response.attempts) {
if (deletedItemSet.contains("attempt:${backupAttempt.id}")) {
continue
}
val attempt = backupAttempt.toAttempt()
val existing = existingAttempts.find { it.id == backupAttempt.id }
if (existing == null || backupAttempt.createdAt >= existing.createdAt) {
if (existing != null) {
repository.updateAttempt(attempt)
} else {
repository.insertAttempt(attempt)
}
}
}
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
repository.clearDeletedItems()
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} finally {
// Re-enable auto-sync
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
val existingSessions = repository.getAllSessions().first()
val existingAttempts = repository.getAllAttempts().first()
for (item in deletions) {
when (item.type) {
"gym" -> {
existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) }
}
"problem" -> {
existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) }
}
"session" -> {
existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) }
}
"attempt" -> {
existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) }
}
}
}
}
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems")
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
val filename = imagePath.substringAfterLast('/')
uploadImage(imagePath, filename)
}
}
}
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.put(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
handleHttpError(response.code)
}
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
}
}
}
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
try {
val localImagePath = downloadImage(serverFilename)
if (localImagePath != null) {
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath")
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
}
}
}
return imagePathMapping
}
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.bytes()?.let {
ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
}
} else {
if (response.code == 404) throw SyncException.ImageNotFound
null
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e)
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
val filename = localPath.substringAfterLast('/')
uploadImage(localPath, filename)
}
}
}
}
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath")
return
}
val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename")
} else {
Log.w(
TAG,
"Failed to upload image $filename. Server responded with ${response.code}"
)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e)
}
}
}
private suspend fun createBackupFromRepository(): ClimbDataBackup {
return withContext(Dispatchers.Default) {
ClimbDataBackup(
exportedAt = dataStateManager.getLastModified(),
gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
problems =
repository.getAllProblems().first().map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(
problem.id,
index
)
}
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
},
sessions =
repository.getAllSessions().first().map {
BackupClimbSession.fromClimbSession(it)
},
attempts =
repository.getAllAttempts().first().map {
BackupAttempt.fromAttempt(it)
},
deletedItems = repository.getDeletedItems()
)
}
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
imagePathMapping: Map<String, String>
) {
val gyms = backup.gyms.map { it.toGym() }
val problems =
backup.problems.map { backupProblem ->
val imagePaths = backupProblem.imagePaths
val updatedImagePaths =
imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath }
backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
val sessions = backup.sessions.map { it.toClimbSession() }
val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
gyms.forEach { repository.insertGymWithoutSync(it) }
problems.forEach { repository.insertProblemWithoutSync(it) }
sessions.forEach { repository.insertSessionWithoutSync(it) }
attempts.forEach { repository.insertAttemptWithoutSync(it) }
repository.clearDeletedItems()
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
private fun handleHttpError(code: Int): Nothing {
when (code) {
401 -> throw SyncException.Unauthorized
in 500..599 -> throw SyncException.ServerError(code)
else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
suspend fun testConnection() { suspend fun testConnection() {
if (!_isConfigured.value) {
_isConnected.value = false
_syncError.value = "Server URL or Auth Token is not set."
return
}
_isTesting.value = true _isTesting.value = true
_syncError.value = null _syncError.value = null
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try { try {
withContext(Dispatchers.IO) { provider.testConnection()
httpClient.newCall(request).execute().use { response ->
_isConnected.value = response.isSuccessful || response.code == 405
}
}
if (!_isConnected.value) {
_syncError.value = "Connection failed. Check URL and token."
}
} catch (e: Exception) { } catch (e: Exception) {
_isConnected.value = false
_syncError.value = "Connection error: ${e.message}" _syncError.value = "Connection error: ${e.message}"
throw e
} finally { } finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false _isTesting.value = false
} }
} }
fun triggerAutoSync() { fun triggerAutoSync() {
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) { if (!isConfiguredFlow.value || !isConnected.value || !_isAutoSyncEnabled.value) {
return return
} }
if (_isSyncing.value) { if (_isSyncing.value) {
@@ -798,7 +142,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
try { try {
syncWithServer() syncWithServer()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e) AppLogger.e(TAG, e) { "Auto-sync failed" }
} }
if (pendingChanges) { if (pendingChanges) {
pendingChanges = false pendingChanges = false
@@ -809,29 +153,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
fun clearConfiguration() { fun clearConfiguration() {
syncJob?.cancel() syncJob?.cancel()
serverUrl = "" provider.disconnect()
authToken = ""
setAutoSyncEnabled(true) setAutoSyncEnabled(true)
_lastSyncTime.value = null _lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null _syncError.value = null
sharedPreferences.edit { clear() }
updateConfiguredState()
} }
} }
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
object Unauthorized : SyncException("Unauthorized. Please check your auth token.")
object ImageNotFound : SyncException("Image not found on server")
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -6,16 +6,21 @@ 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
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() { class SessionTrackingService : Service() {
@@ -28,6 +33,7 @@ class SessionTrackingService : Service() {
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
companion object { companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel" const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session" const val ACTION_START_SESSION = "start_session"
@@ -67,16 +73,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId) startSessionTracking(sessionId)
} }
} }
ACTION_STOP_SESSION -> { ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID) val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch { serviceScope.launch {
try { try {
val targetSession = when { val targetSession =
when {
sessionId != null -> repository.getSessionById(sessionId) sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession() else -> repository.getActiveSession()
} }
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) { if (targetSession != null &&
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() } targetSession.status ==
com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
val completed =
with(com.atridad.ascently.data.model.ClimbSession) {
targetSession.complete()
}
repository.updateSession(completed) repository.updateSession(completed)
} }
} finally { } finally {
@@ -97,11 +111,14 @@ class SessionTrackingService : Service() {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
} }
notificationJob = serviceScope.launch { notificationJob =
serviceScope.launch {
try { try {
if (!isNotificationActive()) { if (!isNotificationActive()) {
delay(1000L) delay(1000L)
@@ -113,11 +130,12 @@ class SessionTrackingService : Service() {
updateNotification(sessionId) updateNotification(sessionId)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
} }
} }
monitoringJob = serviceScope.launch { monitoringJob =
serviceScope.launch {
try { try {
while (isActive) { while (isActive) {
delay(10000L) delay(10000L)
@@ -127,13 +145,17 @@ class SessionTrackingService : Service() {
} }
val session = repository.getSessionById(sessionId) val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { if (session == null ||
session.status !=
com.atridad.ascently.data.model.SessionStatus
.ACTIVE
) {
stopSessionTracking() stopSessionTracking()
break break
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
} }
} }
} }
@@ -143,6 +165,8 @@ class SessionTrackingService : Service() {
monitoringJob?.cancel() monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
} }
private fun isNotificationActive(): Boolean { private fun isNotificationActive(): Boolean {
@@ -157,14 +181,16 @@ class SessionTrackingService : Service() {
private suspend fun updateNotification(sessionId: String) { private suspend fun updateNotification(sessionId: String) {
try { try {
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try { try {
delay(10000L) delay(10000L)
createAndShowNotification(sessionId) createAndShowNotification(sessionId)
} catch (retryException: Exception) { } catch (retryException: Exception) {
retryException.printStackTrace() AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking() stopSessionTracking()
} }
} }
@@ -172,44 +198,22 @@ class SessionTrackingService : Service() {
private fun createAndShowNotification(sessionId: String) { private fun createAndShowNotification(sessionId: String) {
try { try {
val session = runBlocking { val session = runBlocking { repository.getSessionById(sessionId) }
repository.getSessionById(sessionId) if (session == null ||
} session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) { ) {
stopSessionTracking() stopSessionTracking()
return return
} }
val gym = runBlocking { val gym = runBlocking { repository.getGymById(session.gymId) }
repository.getGymById(session.gymId)
}
val attempts = runBlocking { val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList() repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
} }
val duration = session.startTime?.let { startTime -> val notificationBuilder =
try { NotificationCompat.Builder(this, CHANNEL_ID)
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains) .setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
@@ -227,20 +231,77 @@ class SessionTrackingService : Service() {
"End Session", "End Session",
createStopPendingIntent(sessionId) createStopPendingIntent(sessionId)
) )
.build()
// Use Live Update
if (Build.VERSION.SDK_INT >= 36) {
val startTimeMillis =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val zoneId = ZoneId.systemDefault()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
}
?: System.currentTimeMillis()
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}${attempts.size} attempts"
)
.setWhen(startTimeMillis)
.setUsesChronometer(true)
.setShowWhen(true)
val extras = Bundle()
extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true)
notificationBuilder.setExtras(extras)
} else {
// Fallback for older versions
val duration =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
}
val notification = notificationBuilder.build()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e throw e
} }
} }
private fun createOpenAppIntent(): PendingIntent { private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION" action = "OPEN_SESSION"
} }
@@ -263,11 +324,13 @@ class SessionTrackingService : Service() {
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = NotificationChannel( val channel =
NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Session Tracking", "Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
).apply { )
.apply {
description = "Shows active climbing session information" description = "Shows active climbing session information"
setShowBadge(false) setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC

View File

@@ -26,6 +26,7 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.* import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils import com.atridad.ascently.utils.NotificationPermissionUtils
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true launchSingleTop = true
} }
} }
AppShortcutManager.ACTION_END_SESSION -> { AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) { navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
@@ -114,10 +116,7 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
if (activeSession == null) { if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() && if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
@@ -125,14 +124,11 @@ fun AscentlyApp(
context context
) )
) { ) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog") AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else { } else {
if (gyms.size == 1) { if (gyms.size == 1) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
viewModel.startSession(context, gyms.first().id) viewModel.startSession(context, gyms.first().id)
} else { } else {
val targetGym = val targetGym =
@@ -140,25 +136,16 @@ fun AscentlyApp(
?: lastUsedGym ?: lastUsedGym
if (targetGym != null) { if (targetGym != null) {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
viewModel.startSession(context, targetGym.id) viewModel.startSession(context, targetGym.id)
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
"AscentlyApp",
"No target gym found, navigating to selection"
)
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
} }
} else { } else {
android.util.Log.d( AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
} }
onShortcutActionProcessed() onShortcutActionProcessed()

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

@@ -216,9 +216,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
// Manual Sync Button // Manual Sync Button
TextButton( TextButton(
onClick = { onClick = {
coroutineScope.launch {
viewModel.performManualSync() viewModel.performManualSync()
}
}, },
enabled = isConnected && !isSyncing enabled = isConnected && !isSyncing
) { ) {
@@ -583,41 +581,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

@@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File import java.io.File
@@ -192,7 +193,7 @@ class ClimbViewModel(
} }
} }
println("Deleted $deletedCount image files and cleared image references") AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
} }
} }
@@ -233,12 +234,12 @@ class ClimbViewModel(
// Active session management // Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) { fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch { viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId") AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" }
if (!com.atridad.ascently.utils.NotificationPermissionUtils if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context) .isNotificationPermissionGranted(context)
) { ) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted") AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = error =
@@ -249,10 +250,7 @@ class ClimbViewModel(
val existingActive = repository.getActiveSession() val existingActive = repository.getActiveSession()
if (existingActive != null) { if (existingActive != null) {
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
_uiState.value = _uiState.value =
_uiState.value.copy( _uiState.value.copy(
error = "There's already an active session. Please end it first." error = "There's already an active session. Please end it first."
@@ -260,14 +258,11 @@ class ClimbViewModel(
return@launch return@launch
} }
android.util.Log.d("ClimbViewModel", "Creating new session") AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes) val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession) repository.insertSession(newSession)
android.util.Log.d( AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
// Start the tracking service // Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id) val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent) context.startForegroundService(serviceIntent)
@@ -416,13 +411,15 @@ class ClimbViewModel(
} }
// Sync-related methods // Sync-related methods
suspend fun performManualSync() { fun performManualSync() {
viewModelScope.launch {
try { try {
syncService.syncWithServer() syncService.syncWithServer()
} catch (e: Exception) { } catch (e: Exception) {
setError("Sync failed: ${e.message}") setError("Sync failed: ${e.message}")
} }
} }
}
suspend fun testSyncConnection() { suspend fun testSyncConnection() {
try { try {
@@ -477,15 +474,12 @@ class ClimbViewModel(
result.onFailure { error -> result.onFailure { error ->
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w( AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (healthConnectManager.isReadySync()) { if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}") AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
} }
} }
} }

View File

@@ -0,0 +1,48 @@
package com.atridad.ascently.utils
import android.util.Log
import com.atridad.ascently.BuildConfig
object AppLogger {
private const val DEFAULT_TAG = "Ascently"
enum class Level(val androidLevel: Int) {
DEBUG(Log.DEBUG),
INFO(Log.INFO),
WARN(Log.WARN),
ERROR(Log.ERROR)
}
fun d(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.DEBUG, tag, messageProvider)
}
fun i(tag: String = DEFAULT_TAG, messageProvider: () -> String) {
log(Level.INFO, tag, messageProvider)
}
fun w(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.WARN, tag, messageProvider, throwable)
}
fun e(tag: String = DEFAULT_TAG, throwable: Throwable? = null, messageProvider: () -> String) {
log(Level.ERROR, tag, messageProvider, throwable)
}
private fun log(
level: Level,
tag: String,
messageProvider: () -> String,
throwable: Throwable? = null
) {
if (!BuildConfig.DEBUG) return
val message = messageProvider()
if (throwable != null) {
Log.println(level.androidLevel, tag, "$message\n${Log.getStackTraceString(throwable)}")
} else {
Log.println(level.androidLevel, tag, message)
}
}
}

View File

@@ -6,7 +6,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
@@ -73,7 +72,7 @@ object ImageUtils {
compressedBitmap.recycle() compressedBitmap.recycle()
true true
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false false
} }
} }
@@ -119,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath) val file = getImageFile(context, relativePath)
file.delete() file.delete()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false false
} }
} }
@@ -137,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true) sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null null
} }
} }
@@ -157,7 +156,7 @@ object ImageUtils {
} }
?: emptyList() ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList() emptyList()
} }
} }
@@ -178,7 +177,7 @@ object ImageUtils {
tempFilename tempFilename
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e) AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null null
} }
} }
@@ -193,7 +192,7 @@ object ImageUtils {
return try { return try {
val tempFile = File(getImagesDirectory(context), tempFilename) val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) { if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename") AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null return null
} }
@@ -202,17 +201,14 @@ object ImageUtils {
val finalFile = File(getImagesDirectory(context), deterministicFilename) val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) { if (tempFile.renameTo(finalFile)) {
Log.d( AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
deterministicFilename deterministicFilename
} else { } else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename") AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e) AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null null
} }
} }
@@ -249,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes() destExif.saveAttributes()
} catch (e: Exception) { } catch (e: Exception) {
// If EXIF preservation fails, continue without it // If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}") AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" }
} }
bitmap.recycle() bitmap.recycle()
@@ -262,7 +258,7 @@ object ImageUtils {
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null null
} }
} }
@@ -275,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) } orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
} }
} }
} }

View File

@@ -2,7 +2,6 @@ package com.atridad.ascently.utils
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
class MigrationManager(private val context: Context) { class MigrationManager(private val context: Context) {
@@ -22,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/ */
fun migrateIfNeeded() { fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) { if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping") AppLogger.d(TAG) { "Migration already completed, skipping" }
return return
} }
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...") AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0 var migrationCount = 0
// Migrate SharedPreferences // Migrate SharedPreferences
@@ -36,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) { if (migrationCount > 0) {
Log.i( AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
} else { } else {
Log.i(TAG, " No OpenClimb data found to migrate") AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
} }
} }
@@ -95,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences // Clear old preferences
oldPrefs.edit { clear() } oldPrefs.edit { clear() }
Log.d( AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
return oldPrefs.all.size return oldPrefs.all.size
} }
@@ -150,7 +143,7 @@ class MigrationManager(private val context: Context) {
} }
} }
Log.d(TAG, "Migrated ${keysToMigrate.size} keys in $prefFileName") AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" }
count += keysToMigrate.size count += keysToMigrate.size
} }
} }
@@ -166,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */ /** Reset migration state (for testing purposes) */
fun resetMigrationState() { fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) } migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset") AppLogger.d(TAG) { "Migration state reset" }
} }
} }

View File

@@ -52,7 +52,8 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
successfulImages++ successfulImages++
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils",
"Image file not found or empty: $imagePath" "Image file not found or empty: $imagePath"
) }
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}" "Failed to add image $imagePath: ${e.message}"
) }
} }
} }
// Log export summary // Log export summary
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included" "Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
) }
} }
// Validate the created ZIP file // Validate the created ZIP file
@@ -131,7 +129,8 @@ object ZipExportImportUtils {
zipOut.closeEntry() zipOut.closeEntry()
// Add JSON data file // Add JSON data file
val json = Json { val json =
Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
@@ -158,17 +157,15 @@ object ZipExportImportUtils {
successfulImages++ successfulImages++
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}" "Failed to add image $imagePath: ${e.message}"
) }
} }
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils",
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
) }
} }
} }
?: throw IOException("Could not open output stream") ?: throw IOException("Could not open output stream")
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation // Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata") foundRequiredFiles.add("metadata")
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils",
"Found metadata: ${metadataContent.lines().take(3).joinToString()}" "Found metadata: ${metadataContent.lines().take(3).joinToString()}"
)
} }
}
entry.name == DATA_JSON_FILENAME -> { entry.name == DATA_JSON_FILENAME -> {
// Read JSON data // Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data") foundRequiredFiles.add("data")
} }
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file // Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile) val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) { if (newPath != null) {
importedImagePaths[originalFilename] = newPath importedImagePaths[originalFilename] = newPath
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath" "Successfully imported image: $originalFilename -> $newPath"
)
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
} }
} else { } else {
android.util.Log.w( AppLogger.w("ZipExportImportUtils") {
"ZipExportImportUtils", "Failed to import image: $originalFilename"
}
}
} else {
AppLogger.w("ZipExportImportUtils") {
"Extracted image is empty: $originalFilename" "Extracted image is empty: $originalFilename"
) }
} }
// Clean up temp file // Clean up temp file
tempFile.delete() tempFile.delete()
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e( AppLogger.e("ZipExportImportUtils", e) {
"ZipExportImportUtils",
"Failed to process image $originalFilename: ${e.message}" "Failed to process image $originalFilename: ${e.message}"
)
} }
} }
}
else -> { else -> {
android.util.Log.d( AppLogger.d("ZipExportImportUtils") {
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}" "Skipping ZIP entry: ${entry.name}"
) }
} }
} }
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty") throw IOException("Invalid ZIP file: data.json is empty")
} }
android.util.Log.i( AppLogger.i("ZipExportImportUtils") {
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed" "Import extraction completed: ${importedImagePaths.size} images processed"
) }
return ImportResult(jsonContent, importedImagePaths) return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -11,6 +11,7 @@ import com.atridad.ascently.MainActivity
import com.atridad.ascently.R import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.repository.ClimbRepository
import java.time.LocalDate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -48,53 +49,47 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
val database = AscentlyDatabase.getDatabase(context) val database = AscentlyDatabase.getDatabase(context)
val repository = ClimbRepository(database, context) val repository = ClimbRepository(database, context)
// Fetch stats data // Get last 7 days date range (rolling period)
val today = LocalDate.now()
val sevenDaysAgo = today.minusDays(6) // Today + 6 days ago = 7 days total
// Fetch all sessions and attempts
val sessions = repository.getAllSessions().first() val sessions = repository.getAllSessions().first()
val problems = repository.getAllProblems().first()
val attempts = repository.getAllAttempts().first() val attempts = repository.getAllAttempts().first()
val gyms = repository.getAllGyms().first()
// Calculate stats // Filter for last 7 days across all gyms
val completedSessions = sessions.filter { it.endTime != null } val weekSessions =
sessions.filter { session ->
// Count problems that have been completed (have at least one successful attempt) try {
val completedProblems = val sessionDate = LocalDate.parse(session.date.substring(0, 10))
problems !sessionDate.isBefore(sevenDaysAgo) && !sessionDate.isAfter(today)
.filter { problem -> } catch (_: Exception) {
attempts.any { attempt -> false
attempt.problemId == problem.id &&
(attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.SUCCESS ||
attempt.result ==
com.atridad.ascently.data.model
.AttemptResult.FLASH)
} }
} }
.size
val favoriteGym = val weekSessionIds = weekSessions.map { it.id }.toSet()
sessions.groupBy { it.gymId }.maxByOrNull { it.value.size }?.let {
(gymId, _) -> // Count total attempts this week
gyms.find { it.id == gymId }?.name val totalAttempts =
} attempts.count { attempt -> weekSessionIds.contains(attempt.sessionId) }
?: "No sessions yet"
// Count sessions this week
val totalSessions = weekSessions.size
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats) val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText( // Set weekly stats
R.id.widget_total_sessions, views.setTextViewText(R.id.widget_attempts_value, totalAttempts.toString())
completedSessions.size.toString() views.setTextViewText(R.id.widget_sessions_value, totalSessions.toString())
)
views.setTextViewText(
R.id.widget_problems_completed,
completedProblems.toString()
)
views.setTextViewText(R.id.widget_total_problems, problems.size.toString())
views.setTextViewText(R.id.widget_favorite_gym, favoriteGym)
val intent = Intent(context, MainActivity::class.java) val intent =
Intent(context, MainActivity::class.java).apply {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = val pendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
@@ -110,10 +105,8 @@ class ClimbStatsWidgetProvider : AppWidgetProvider() {
} catch (_: Exception) { } catch (_: Exception) {
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
val views = RemoteViews(context.packageName, R.layout.widget_climb_stats) val views = RemoteViews(context.packageName, R.layout.widget_climb_stats)
views.setTextViewText(R.id.widget_total_sessions, "0") views.setTextViewText(R.id.widget_attempts_value, "0")
views.setTextViewText(R.id.widget_problems_completed, "0") views.setTextViewText(R.id.widget_sessions_value, "0")
views.setTextViewText(R.id.widget_total_problems, "0")
views.setTextViewText(R.id.widget_favorite_gym, "No data")
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
val pendingIntent = val pendingIntent =

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M9,11.24V7.5C9,6.12 10.12,5 11.5,5S14,6.12 14,7.5v3.74c1.21,-0.81 2,-2.18 2,-3.74C16,5.01 13.99,3 11.5,3S7,5.01 7,7.5C7,9.06 7.79,10.43 9,11.24zM18.84,15.87l-4.54,-2.26c-0.17,-0.07 -0.35,-0.11 -0.54,-0.11H13v-6C13,6.67 12.33,6 11.5,6S10,6.67 10,7.5v10.74l-3.43,-0.72c-0.08,-0.01 -0.15,-0.03 -0.24,-0.03c-0.31,0 -0.59,0.13 -0.79,0.33l-0.79,0.8l4.94,4.94C9.96,23.83 10.34,24 10.75,24h6.79c0.75,0 1.33,-0.55 1.44,-1.28l0.75,-5.27c0.01,-0.07 0.02,-0.14 0.02,-0.2C19.75,16.63 19.37,16.09 18.84,15.87z"/>
</vector>

View File

@@ -5,190 +5,84 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/widget_background" android:background="@drawable/widget_background"
android:orientation="vertical" android:orientation="vertical"
android:padding="12dp"> android:padding="12dp"
android:gravity="center">
<!-- Header --> <!-- Header with icon and "Weekly" text -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp"> android:layout_marginBottom="12dp">
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="28dp"
android:layout_height="24dp" android:layout_height="28dp"
android:src="@drawable/ic_mountains" android:src="@drawable/ic_mountains"
android:tint="@color/widget_primary" android:tint="@color/widget_primary"
android:layout_marginEnd="8dp" /> android:layout_marginEnd="8dp"
android:contentDescription="@string/ascently_icon" />
<TextView <TextView
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:text="@string/weekly"
android:text="Ascently" android:textSize="18sp"
android:textSize="16sp" android:textColor="@color/widget_text_primary" />
</LinearLayout>
<!-- Attempts Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_circle_filled"
android:tint="@color/widget_primary"
android:layout_marginEnd="12dp"
android:contentDescription="Attempts icon" />
<TextView
android:id="@+id/widget_attempts_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="40sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" /> android:textColor="@color/widget_text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Climbing Stats"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary" />
</LinearLayout> </LinearLayout>
<!-- Stats Grid --> <!-- Sessions Row -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<!-- Top Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_marginBottom="8dp"> android:gravity="center_vertical">
<!-- Sessions Card --> <ImageView
<LinearLayout android:layout_width="32dp"
android:layout_width="0dp" android:layout_height="32dp"
android:layout_height="match_parent" android:src="@drawable/ic_play_arrow_24"
android:layout_weight="1" android:tint="@color/widget_primary"
android:orientation="vertical" android:layout_marginEnd="12dp"
android:gravity="center" android:contentDescription="@string/sessions_icon" />
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView <TextView
android:id="@+id/widget_total_sessions" android:id="@+id/widget_sessions_value"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="0" android:text="@string/_0"
android:textSize="22sp" android:textSize="40sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_primary" /> android:textColor="@color/widget_text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sessions"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Problems Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_problems_completed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Completed"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<!-- Success Rate Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginEnd="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_total_problems"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/widget_secondary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Problems"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Favorite Gym Card -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:background="@drawable/widget_stat_card_background"
android:layout_marginStart="4dp"
android:padding="12dp">
<TextView
android:id="@+id/widget_favorite_gym"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No gyms"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="@color/widget_accent"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Favorite"
android:textSize="12sp"
android:textColor="@color/widget_text_secondary"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -12,5 +12,9 @@
<string name="shortcut_end_session_disabled">No active session to end</string> <string name="shortcut_end_session_disabled">No active session to end</string>
<!-- Widget --> <!-- Widget -->
<string name="widget_description">View your climbing stats at a glance</string> <string name="widget_description">View your weekly climbing stats</string>
<string name="ascently_icon">Ascently icon</string>
<string name="weekly">Weekly</string>
<string name="sessions_icon">Sessions icon</string>
<string name="_0">0</string>
</resources> </resources>

View File

@@ -5,10 +5,6 @@
--> -->
<data-extraction-rules> <data-extraction-rules>
<cloud-backup> <cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup> </cloud-backup>
<!-- <!--
<device-transfer> <device-transfer>

View File

@@ -3,15 +3,14 @@
android:description="@string/widget_description" android:description="@string/widget_description"
android:initialKeyguardLayout="@layout/widget_climb_stats" android:initialKeyguardLayout="@layout/widget_climb_stats"
android:initialLayout="@layout/widget_climb_stats" android:initialLayout="@layout/widget_climb_stats"
android:minWidth="250dp" android:minWidth="110dp"
android:minHeight="180dp" android:minHeight="110dp"
android:maxResizeWidth="110dp"
android:maxResizeHeight="110dp"
android:previewImage="@drawable/ic_mountains" android:previewImage="@drawable/ic_mountains"
android:previewLayout="@layout/widget_climb_stats" android:previewLayout="@layout/widget_climb_stats"
android:resizeMode="horizontal|vertical" android:resizeMode="none"
android:targetCellWidth="4" android:targetCellWidth="2"
android:targetCellHeight="2" android:targetCellHeight="2"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" android:widgetCategory="home_screen" />
android:widgetFeatures="reconfigurable"
android:maxResizeWidth="320dp"
android:maxResizeHeight="240dp" />

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,9 +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.5", "astro": "^5.16.3",
"sharp": "^0.34.4" "qrcode": "^1.5.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6"
} }
} }

1398
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
---
import { Tabs, TabItem } from "@astrojs/starlight/components";
import { Card, CardGrid } from "@astrojs/starlight/components";
import { LinkButton } from "@astrojs/starlight/components";
import { Badge } from "@astrojs/starlight/components";
import QRCode from "./QRCode.astro";
import { downloadLinks, requirements } from "../config";
interface Props {
showQR?: boolean;
}
const { showQR = false } = Astro.props;
const hasLink = (link: string | undefined) => link && link.trim() !== "";
---
<Tabs syncKey="platform">
<TabItem label="Android" icon="star">
<CardGrid>
{
hasLink(downloadLinks.android.playStore) && (
<Card title="Google Play Store" icon="star">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.playStore}
variant="primary"
icon="external"
>
Get on Play Store
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.playStore}
size={200}
alt="QR code for Play Store"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.android.obtainium) && (
<Card title="Obtainium" icon="rocket">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.obtainium}
variant="primary"
icon="external"
>
Get on Obtainium
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.obtainium}
size={200}
alt="QR code for Obtainium"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.android.releases) && (
<Card title="Direct Download" icon="download">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.android.releases}
variant="secondary"
icon="external"
>
Download APK
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.android.releases}
size={200}
alt="QR code for APK download"
/>
</p>
)}
</Card>
)
}
</CardGrid>
<p><strong>Requirements:</strong> {requirements.android}</p>
</TabItem>
<TabItem label="iOS" icon="apple">
<CardGrid>
{
hasLink(downloadLinks.ios.appStore) && (
<Card title="App Store" icon="rocket">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.ios.appStore}
variant="primary"
icon="external"
>
Download on App Store
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.ios.appStore}
size={200}
alt="QR code for App Store"
/>
</p>
)}
</Card>
)
}
{
hasLink(downloadLinks.ios.testFlight) && (
<Card title="TestFlight Beta" icon="warning">
<p style="text-align: center;">
<LinkButton
href={downloadLinks.ios.testFlight}
variant="secondary"
icon="external"
>
Join TestFlight
</LinkButton>
</p>
{showQR && (
<p style="text-align: center;">
<QRCode
data={downloadLinks.ios.testFlight}
size={200}
alt="QR code for TestFlight"
/>
</p>
)}
</Card>
)
}
</CardGrid>
<p><strong>Requirements:</strong> {requirements.ios}</p>
</TabItem>
</Tabs>

View File

@@ -0,0 +1,111 @@
---
import * as QR from "qrcode";
interface Props {
data: string;
size?: number;
alt?: string;
}
const { data, size = 200, alt = "QR Code" } = Astro.props;
// Generate QR code for dark mode
let darkModeQR = "";
try {
darkModeQR = await QR.toDataURL(data, {
width: size,
margin: 2,
color: {
dark: "#FFBF00",
light: "#17181C",
},
});
} catch (err) {
console.error("Failed to generate dark mode QR code:", err);
}
// Generate QR code for light mode
let lightModeQR = "";
try {
lightModeQR = await QR.toDataURL(data, {
width: size,
margin: 2,
color: {
dark: "#F24B3C",
light: "#FFFFFF",
},
});
} catch (err) {
console.error("Failed to generate light mode QR code:", err);
}
const uniqueId = `qr-${Math.random().toString(36).substr(2, 9)}`;
---
{
(darkModeQR || lightModeQR) && (
<img
id={uniqueId}
alt={alt}
width={size}
height={size}
data-light-src={lightModeQR}
data-dark-src={darkModeQR}
style="margin: auto;"
/>
)
}
<script is:inline define:vars={{ uniqueId, lightModeQR, darkModeQR }}>
(function () {
const img = document.getElementById(uniqueId);
if (!img) return;
const theme = document.documentElement.getAttribute("data-theme");
if (theme === "dark" && darkModeQR) {
img.setAttribute("src", darkModeQR);
} else if (lightModeQR) {
img.setAttribute("src", lightModeQR);
}
})();
</script>
<script>
function updateQRCodes() {
const theme = document.documentElement.getAttribute("data-theme");
const qrImages = document.querySelectorAll(
"img[data-light-src][data-dark-src]",
);
qrImages.forEach((img) => {
const lightSrc = img.getAttribute("data-light-src");
const darkSrc = img.getAttribute("data-dark-src");
if (theme === "dark" && darkSrc) {
img.setAttribute("src", darkSrc);
} else if (lightSrc) {
img.setAttribute("src", lightSrc);
}
});
}
// Set initial theme on page load
updateQRCodes();
// Watch for theme changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "data-theme"
) {
updateQRCodes();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
</script>

17
docs/src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
export const requirements = {
android: "Android 12+",
ios: "iOS 17+",
} as const;
export const downloadLinks = {
android: {
releases: "https://git.atri.dad/atridad/Ascently/tags?q=Android",
obtainium:
"https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://git.atri.dad/atridad/Ascently/releases",
playStore: "",
},
ios: {
appStore: "https://apps.apple.com/ca/app/ascently/id6753959144",
testFlight: "https://testflight.apple.com/join/E2DYRGH8",
},
} as const;

View File

@@ -1,26 +0,0 @@
---
title: Download
description: Get Ascently on your Android or iOS device
---
## Android
### Option 1: Direct APK Download
Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases).
### Option 2: Obtainium
Use Obtainium for automatic updates:
[<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Obtainium" height="41">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
## iOS
### App Store
Download from the app store [here](https://apps.apple.com/ca/app/ascently/id6753959144)
### TestFlight Beta
Join the TestFlight beta [here](https://testflight.apple.com/join/E2DYRGH8)
## Requirements
- **Android 12+** or **iOS 17+**

View File

@@ -0,0 +1,10 @@
---
title: Download
description: Get Ascently on your Android or iOS device
---
import DownloadButtons from '../../components/DownloadButtons.astro';
Get Ascently on your device and start tracking your climbs today!
<DownloadButtons showQR={true} />

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

@@ -25,7 +25,7 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
} catch { } catch {
print("Failed to start live activity: \(error)") AppLogger.error("Failed to start live activity: \(error)", tag: "LegacyLiveActivityManager")
} }
} }

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

View File

@@ -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] = []
@@ -91,11 +91,12 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("App will enter foreground - preparing Live Activity check") Task { @MainActor in
Task { AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active // Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background // Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
@@ -107,10 +108,11 @@ struct ContentView: View {
object: nil, object: nil,
queue: .main queue: .main
) { _ in ) { _ in
print("App did become active - checking Live Activity status") Task { @MainActor in
Task { AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle")
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }

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>

View File

@@ -38,7 +38,7 @@ class HealthKitService: ObservableObject {
{ {
currentWorkoutStartDate = startDate currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId currentWorkoutSessionId = sessionId
print("HealthKit: Restored active workout from \(startDate)") AppLogger.info("HealthKit: Restored active workout from \(startDate)", tag: "HealthKit")
} }
} }
@@ -56,31 +56,34 @@ class HealthKitService: ObservableObject {
guard isEnabled else { return } guard isEnabled else { return }
guard HKHealthStore.isHealthDataAvailable() else { guard HKHealthStore.isHealthDataAvailable() else {
print("HealthKit: Device does not support HealthKit") AppLogger.warning("HealthKit: Device does not support HealthKit", tag: "HealthKit")
return return
} }
checkAuthorization() checkAuthorization()
if !isAuthorized { if !isAuthorized {
print( AppLogger.warning(
"HealthKit: Integration was enabled but authorization lost, attempting to restore..." "HealthKit: Integration was enabled but authorization lost, attempting to restore...",
) tag: "HealthKit")
do { do {
try await requestAuthorization() try await requestAuthorization()
print("HealthKit: Authorization restored successfully") AppLogger.info("HealthKit: Authorization restored successfully", tag: "HealthKit")
} catch { } catch {
print("HealthKit: Failed to restore authorization: \(error.localizedDescription)") AppLogger.error(
"HealthKit: Failed to restore authorization: \(error.localizedDescription)",
tag: "HealthKit")
} }
} else { } else {
print("HealthKit: Integration verified - authorization is valid") AppLogger.info(
"HealthKit: Integration verified - authorization is valid", tag: "HealthKit")
} }
if hasActiveWorkout() { if hasActiveWorkout() {
print( AppLogger.info(
"HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)" "HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)",
) tag: "HealthKit")
} }
} }
@@ -130,7 +133,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = startDate currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId currentWorkoutSessionId = sessionId
persistActiveWorkout() persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)") AppLogger.info("HealthKit: Started workout for session \(sessionId)", tag: "HealthKit")
} }
func endWorkout(endDate: Date) async throws { func endWorkout(endDate: Date) async throws {
@@ -178,15 +181,17 @@ class HealthKitService: ObservableObject {
try await builder.endCollection(at: endDate) try await builder.endCollection(at: endDate)
let workout = try await builder.finishWorkout() let workout = try await builder.finishWorkout()
print( AppLogger.info(
"HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")" "HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")",
) tag: "HealthKit")
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
} catch { } catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)") AppLogger.error(
"HealthKit: Failed to save workout: \(error.localizedDescription)", tag: "HealthKit"
)
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
@@ -199,7 +204,7 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout() persistActiveWorkout()
print("HealthKit: Workout cancelled") AppLogger.info("HealthKit: Workout cancelled", tag: "HealthKit")
} }
func hasActiveWorkout() -> Bool { func hasActiveWorkout() -> Bool {

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,46 @@
import Foundation
/// Centralized logging utility for the iOS app.
///
/// All log output is automatically compiled out in non-debug builds to avoid leaking
/// sensitive information. Use this instead of calling `print` directly.
enum AppLogger {
enum LogLevel: String {
case debug = "DEBUG"
case info = "INFO"
case warning = "WARN"
case error = "ERROR"
}
static func debug(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .debug, tag: tag, message: message())
}
static func info(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .info, tag: tag, message: message())
}
static func warning(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .warning, tag: tag, message: message())
}
static func error(_ message: @autoclosure () -> String, tag: String = #fileID) {
log(level: .error, tag: tag, message: message())
}
static func log(level: LogLevel, tag: String, message: @autoclosure () -> String) {
#if DEBUG
let lastPath = (tag as NSString).lastPathComponent
let resolvedTag = lastPath.isEmpty ? tag : lastPath
Swift.print("[\(level.rawValue)][\(resolvedTag)] \(message())")
#endif
}
}
enum LogTag {
static let climbingData = "ClimbingData"
static let dataManagement = "DataManagementSection"
static let exportData = "ExportDataView"
static let syncSection = "SyncSection"
}

View File

@@ -18,14 +18,17 @@ class DataStateManager {
private init() { private init() {
// Initialize with current timestamp if this is the first time // Initialize with current timestamp if this is the first time
if !isInitialized() { if !isInitialized() {
print("DataStateManager: First time initialization") AppLogger.info("DataStateManager: First time initialization", tag: "DataState")
// Set initial timestamp to a very old date so server data will be considered newer // Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z" let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified) userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized() markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)") AppLogger.info(
"DataStateManager initialized with epoch timestamp: \(epochTime)", tag: "DataState")
} else { } else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())") AppLogger.info(
"DataStateManager: Already initialized, current timestamp: \(getLastModified())",
tag: "DataState")
} }
} }
@@ -34,29 +37,32 @@ class DataStateManager {
func updateDataState() { func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date()) let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified) userDefaults.set(now, forKey: Keys.lastModified)
print("iOS Data state updated to: \(now)") AppLogger.info("iOS Data state updated to: \(now)", tag: "DataState")
} }
func getLastModified() -> String { func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)") AppLogger.debug(
"iOS DataStateManager returning stored timestamp: \(storedTimestamp)",
tag: "DataState")
return storedTimestamp return storedTimestamp
} }
let epochTime = "1970-01-01T00:00:00.000Z" let epochTime = "1970-01-01T00:00:00.000Z"
print("No data state timestamp found - returning epoch time: \(epochTime)") AppLogger.warning(
"No data state timestamp found - returning epoch time: \(epochTime)", tag: "DataState")
return epochTime return epochTime
} }
func setLastModified(_ timestamp: String) { func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified) userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)") AppLogger.info("Data state set to: \(timestamp)", tag: "DataState")
} }
func reset() { func reset() {
userDefaults.removeObject(forKey: Keys.lastModified) userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized) userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset") AppLogger.info("Data state reset", tag: "DataState")
} }
private func isInitialized() -> Bool { private func isInitialized() -> Bool {

View File

@@ -5,6 +5,7 @@ import UIKit
class ImageManager { class ImageManager {
static let shared = ImageManager() static let shared = ImageManager()
private let logTag = "ImageManager"
private let thumbnailCache = NSCache<NSString, UIImage>() private let thumbnailCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default private let fileManager = FileManager.default
@@ -30,7 +31,7 @@ class ImageManager {
// Final integrity check // Final integrity check
if !validateStorageIntegrity() { if !validateStorageIntegrity() {
print("CRITICAL: Storage integrity compromised - attempting emergency recovery") logError("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore() emergencyImageRestore()
} }
@@ -83,7 +84,7 @@ class ImageManager {
return return
} }
print("🔄 Migrating images from OpenClimb to Ascently directory...") logInfo("🔄 Migrating images from OpenClimb to Ascently directory...")
do { do {
// Create parent directory if needed // Create parent directory if needed
@@ -94,16 +95,16 @@ class ImageManager {
// Move the entire directory // Move the entire directory
try fileManager.moveItem(at: legacyDir, to: appSupportDirectory) try fileManager.moveItem(at: legacyDir, to: appSupportDirectory)
print("Successfully migrated image directory from OpenClimb to Ascently") logInfo("Successfully migrated image directory from OpenClimb to Ascently")
} catch { } catch {
print("Failed to migrate image directory: \(error)") logError("Failed to migrate image directory: \(error)")
// If move fails, try to copy instead // If move fails, try to copy instead
do { do {
try fileManager.copyItem(at: legacyDir, to: appSupportDirectory) try fileManager.copyItem(at: legacyDir, to: appSupportDirectory)
print("Successfully copied image directory from OpenClimb to Ascently") logInfo("Successfully copied image directory from OpenClimb to Ascently")
// Don't remove the old directory in case of issues // Don't remove the old directory in case of issues
} catch { } catch {
print("Failed to copy image directory: \(error)") logError("Failed to copy image directory: \(error)")
} }
} }
} }
@@ -122,9 +123,9 @@ class ImageManager {
attributes: [ attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
]) ])
print("Created directory: \(directory.path)") logInfo("Created directory: \(directory.path)")
} catch { } catch {
print("ERROR: Failed to create directory \(directory.path): \(error)") logError("ERROR: Failed to create directory \(directory.path): \(error)")
} }
} }
} }
@@ -141,9 +142,9 @@ class ImageManager {
var backupURL = backupDirectory var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues) try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues) try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup") logInfo("Excluded image directories from iCloud backup")
} catch { } catch {
print("WARNING: Failed to exclude from iCloud backup: \(error)") logWarning("WARNING: Failed to exclude from iCloud backup: \(error)")
} }
} }
@@ -167,11 +168,11 @@ class ImageManager {
} }
private func performRobustMigration() { private func performRobustMigration() {
print("Starting robust image migration system...") logInfo("Starting robust image migration system...")
// Check for interrupted migration // Check for interrupted migration
if let incompleteState = loadMigrationState() { if let incompleteState = loadMigrationState() {
print("Detected interrupted migration, resuming...") logInfo("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState) resumeMigration(from: incompleteState)
} else { } else {
// Start fresh migration // Start fresh migration
@@ -188,7 +189,7 @@ class ImageManager {
private func startNewMigration() { private func startNewMigration() {
// First check for images in previous Application Support directories // First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("Found images in previous Application Support directory") logInfo("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -198,7 +199,7 @@ class ImageManager {
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path) let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else { guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate") logInfo("No legacy images to migrate")
return return
} }
@@ -213,7 +214,7 @@ class ImageManager {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles) allLegacyFiles.append(contentsOf: legacyFiles)
print("Found \(legacyFiles.count) images in OpenClimbImages") logInfo("Found \(legacyFiles.count) images in OpenClimbImages")
} }
// Collect files from Documents/images directory // Collect files from Documents/images directory
@@ -221,10 +222,10 @@ class ImageManager {
let importFiles = try fileManager.contentsOfDirectory( let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path) atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles) allLegacyFiles.append(contentsOf: importFiles)
print("Found \(importFiles.count) images in Documents/images") logInfo("Found \(importFiles.count) images in Documents/images")
} }
print("Total legacy images to migrate: \(allLegacyFiles.count)") logInfo("Total legacy images to migrate: \(allLegacyFiles.count)")
let initialState = MigrationState( let initialState = MigrationState(
version: MigrationState.currentVersion, version: MigrationState.currentVersion,
@@ -239,24 +240,24 @@ class ImageManager {
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState) performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch { } catch {
print("ERROR: Failed to start migration: \(error)") logError("ERROR: Failed to start migration: \(error)")
} }
} }
private func resumeMigration(from state: MigrationState) { private func resumeMigration(from state: MigrationState) {
print("Resuming migration from checkpoint...") logInfo("Resuming migration from checkpoint...")
print("Progress: \(state.completedFiles.count)/\(state.totalFiles)") logInfo("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do { do {
let legacyFiles = try fileManager.contentsOfDirectory( let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path) atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) } let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
print("Resuming with \(remainingFiles.count) remaining files") logInfo("Resuming with \(remainingFiles.count) remaining files")
performMigrationWithCheckpoints(files: remainingFiles, currentState: state) performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch { } catch {
print("ERROR: Failed to resume migration: \(error)") logError("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh // Fallback: start fresh
removeMigrationState() removeMigrationState()
startNewMigration() startNewMigration()
@@ -323,11 +324,11 @@ class ImageManager {
completedFiles.append(fileName) completedFiles.append(fileName)
migratedCount += 1 migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))") logInfo("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch { } catch {
failedCount += 1 failedCount += 1
print("ERROR: Failed to migrate \(fileName): \(error)") logError("ERROR: Failed to migrate \(fileName): \(error)")
} }
// Save checkpoint every 5 files or if interrupted // Save checkpoint every 5 files or if interrupted
@@ -341,7 +342,7 @@ class ImageManager {
lastCheckpoint: Date() lastCheckpoint: Date()
) )
saveMigrationState(checkpointState) saveMigrationState(checkpointState)
print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)") logInfo("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
} }
} }
} }
@@ -357,7 +358,7 @@ class ImageManager {
) )
saveMigrationState(finalState) saveMigrationState(finalState)
print("Migration complete: \(migratedCount) migrated, \(failedCount) failed") logInfo("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures // Clean up legacy directory if no failures
if failedCount == 0 { if failedCount == 0 {
@@ -366,7 +367,7 @@ class ImageManager {
} }
private func verifyMigrationIntegrity() { private func verifyMigrationIntegrity() {
print("Verifying migration integrity...") logInfo("Verifying migration integrity...")
var allLegacyFiles = Set<String>() var allLegacyFiles = Set<String>()
@@ -384,12 +385,12 @@ class ImageManager {
allLegacyFiles.formUnion(importFiles) allLegacyFiles.formUnion(importFiles)
} }
} catch { } catch {
print("ERROR: Failed to read legacy directories: \(error)") logError("ERROR: Failed to read legacy directories: \(error)")
return return
} }
guard !allLegacyFiles.isEmpty else { guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against") logInfo("No legacy directories to verify against")
return return
} }
@@ -400,10 +401,10 @@ class ImageManager {
let missingFiles = allLegacyFiles.subtracting(migratedFiles) let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty { if missingFiles.isEmpty {
print("Migration integrity verified - all files present") logInfo("Migration integrity verified - all files present")
cleanupLegacyDirectory() cleanupLegacyDirectory()
} else { } else {
print("WARNING: Missing \(missingFiles.count) files, re-triggering migration") logWarning("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files // Re-trigger migration for missing files
performMigrationWithCheckpoints( performMigrationWithCheckpoints(
files: Array(missingFiles), files: Array(missingFiles),
@@ -417,16 +418,16 @@ class ImageManager {
)) ))
} }
} catch { } catch {
print("ERROR: Failed to verify migration integrity: \(error)") logError("ERROR: Failed to verify migration integrity: \(error)")
} }
} }
private func cleanupLegacyDirectory() { private func cleanupLegacyDirectory() {
do { do {
try fileManager.removeItem(at: legacyImagesDirectory) try fileManager.removeItem(at: legacyImagesDirectory)
print("Cleaned up legacy directory") logInfo("Cleaned up legacy directory")
} catch { } catch {
print("WARNING: Failed to clean up legacy directory: \(error)") logWarning("WARNING: Failed to clean up legacy directory: \(error)")
} }
} }
@@ -446,16 +447,16 @@ class ImageManager {
let data = try Data(contentsOf: migrationStateURL) let data = try Data(contentsOf: migrationStateURL)
let state = try JSONDecoder().decode(MigrationState.self, from: data) let state = try JSONDecoder().decode(MigrationState.self, from: data)
// Check if state is too old (more than 1 hour) // Check if state is too old
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 { if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("WARNING: Migration state is stale, starting fresh") logWarning("WARNING: Migration state is stale, starting fresh")
removeMigrationState() removeMigrationState()
return nil return nil
} }
return state.isComplete ? nil : state return state.isComplete ? nil : state
} catch { } catch {
print("ERROR: Failed to load migration state: \(error)") logError("ERROR: Failed to load migration state: \(error)")
removeMigrationState() removeMigrationState()
return nil return nil
} }
@@ -466,7 +467,7 @@ class ImageManager {
let data = try JSONEncoder().encode(state) let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL) try data.write(to: migrationStateURL)
} catch { } catch {
print("ERROR: Failed to save migration state: \(error)") logError("ERROR: Failed to save migration state: \(error)")
} }
} }
@@ -482,7 +483,7 @@ class ImageManager {
private func cleanupMigrationState() { private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL) try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL) try? fileManager.removeItem(at: migrationLockURL)
print("Cleaned up migration state files") logInfo("Cleaned up migration state files")
} }
func saveImageData(_ data: Data, withName name: String? = nil) -> String? { func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
@@ -497,10 +498,10 @@ class ImageManager {
// Create backup copy // Create backup copy
try data.write(to: backupPath) try data.write(to: backupPath)
print("Saved image with backup: \(fileName)") logInfo("Saved image with backup: \(fileName)")
return fileName return fileName
} catch { } catch {
print("ERROR: Failed to save image \(fileName): \(error)") logError("ERROR: Failed to save image \(fileName): \(error)")
return nil return nil
} }
} }
@@ -520,7 +521,7 @@ class ImageManager {
if fileManager.fileExists(atPath: backupPath.path), if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath) let data = try? Data(contentsOf: backupPath)
{ {
print("Restored image from backup: \(path)") logInfo("Restored image from backup: \(path)")
// Restore to primary location // Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath)) try? data.write(to: URL(fileURLWithPath: primaryPath))
@@ -595,7 +596,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(atPath: primaryPath) try fileManager.removeItem(atPath: primaryPath)
} catch { } catch {
print("ERROR: Failed to delete primary image at \(primaryPath): \(error)") logError("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false success = false
} }
} }
@@ -605,7 +606,7 @@ class ImageManager {
do { do {
try fileManager.removeItem(at: backupPath) try fileManager.removeItem(at: backupPath)
} catch { } catch {
print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)") logError("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false success = false
} }
} }
@@ -642,7 +643,7 @@ class ImageManager {
} }
func performMaintenance() { func performMaintenance() {
print("Starting image maintenance...") logInfo("Starting image maintenance...")
syncBackups() syncBackups()
validateImageIntegrity() validateImageIntegrity()
@@ -660,11 +661,11 @@ class ImageManager {
let backupPath = backupDirectory.appendingPathComponent(fileName) let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath) try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("Created missing backup for: \(fileName)") logInfo("Created missing backup for: \(fileName)")
} }
} }
} catch { } catch {
print("ERROR: Failed to sync backups: \(error)") logError("ERROR: Failed to sync backups: \(error)")
} }
} }
@@ -683,14 +684,14 @@ class ImageManager {
} }
} }
print("Validated \(validFiles) of \(files.count) image files") logInfo("Validated \(validFiles) of \(files.count) image files")
} catch { } catch {
print("ERROR: Failed to validate images: \(error)") logError("ERROR: Failed to validate images: \(error)")
} }
} }
private func cleanupOrphanedFiles() { private func cleanupOrphanedFiles() {
print("Cleanup would require coordination with data manager") logInfo("Cleanup would require coordination with data manager")
} }
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) { func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
@@ -718,7 +719,7 @@ class ImageManager {
private func logDirectoryInfo() { private func logDirectoryInfo() {
let info = getStorageInfo() let info = getStorageInfo()
let previousDir = findPreviousAppSupportImages() let previousDir = findPreviousAppSupportImages()
print( logInfo(
""" """
Ascently Image Storage: Ascently Image Storage:
- App Support: \(appSupportDirectory.path) - App Support: \(appSupportDirectory.path)
@@ -732,7 +733,7 @@ class ImageManager {
} }
func forceRecoveryMigration() { func forceRecoveryMigration() {
print("FORCE RECOVERY: Starting manual migration recovery...") logInfo("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state // Remove any stale state
removeMigrationState() removeMigrationState()
@@ -741,7 +742,7 @@ class ImageManager {
// Force fresh migration // Force fresh migration
startNewMigration() startNewMigration()
print("FORCE RECOVERY: Migration recovery completed") logInfo("FORCE RECOVERY: Migration recovery completed")
} }
func saveImportedImage(_ imageData: Data, filename: String) throws -> String { func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
@@ -754,12 +755,12 @@ class ImageManager {
// Create backup // Create backup
try? imageData.write(to: backupPath) try? imageData.write(to: backupPath)
print("Imported image: \(filename)") logInfo("Imported image: \(filename)")
return filename return filename
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("EMERGENCY: Attempting image restoration...") logError("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory // Try to restore from backup directory
do { do {
@@ -777,14 +778,14 @@ class ImageManager {
} }
} }
print("EMERGENCY: Restored \(restoredCount) images from backup") logError("EMERGENCY: Restored \(restoredCount) images from backup")
} catch { } catch {
print("EMERGENCY: Failed to restore from backup: \(error)") logError("EMERGENCY: Failed to restore from backup: \(error)")
} }
// Try previous Application Support directories first // Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() { if let previousAppSupportImages = findPreviousAppSupportImages() {
print("EMERGENCY: Found previous Application Support images, migrating...") logError("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return return
} }
@@ -793,23 +794,21 @@ class ImageManager {
if fileManager.fileExists(atPath: legacyImagesDirectory.path) if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path) || fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{ {
print("EMERGENCY: Attempting legacy migration as fallback...") logError("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration() forceRecoveryMigration()
} }
} }
func debugSafeInitialization() -> Bool { func debugSafeInitialization() -> Bool {
print("DEBUG SAFE: Performing debug-safe initialization check...") logDebug("DEBUG SAFE: Performing debug-safe initialization check...")
// Check if we're in a debug environment // Check if we're in a debug environment
#if DEBUG #if DEBUG
print("DEBUG SAFE: Debug environment detected") logDebug("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) { if fileManager.fileExists(atPath: migrationLockURL.path) {
print("DEBUG SAFE: Found migration lock - likely debug interruption") logDebug("DEBUG SAFE: Found migration lock - likely debug interruption")
// Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0) Thread.sleep(forTimeInterval: 1.0)
// Try emergency recovery // Try emergency recovery
@@ -829,14 +828,14 @@ class ImageManager {
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0 ((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles { if primaryEmpty && backupHasFiles {
print("DEBUG SAFE: Primary empty but backup exists - restoring") logDebug("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore() emergencyImageRestore()
return true return true
} }
// Check if primary storage is empty but previous Application Support images exist // Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() { if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
print("DEBUG SAFE: Primary empty but found previous Application Support images") logDebug("DEBUG SAFE: Primary empty but found previous Application Support images")
migratePreviousAppSupportImages(from: previousAppSupportImages) migratePreviousAppSupportImages(from: previousAppSupportImages)
return true return true
} }
@@ -852,7 +851,7 @@ class ImageManager {
// Check if we have more backups than primary files (sign of corruption) // Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 { if backupFiles.count > primaryFiles.count + 5 {
print( logInfo(
"WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption" "WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption"
) )
return false return false
@@ -860,7 +859,7 @@ class ImageManager {
// Check if primary is completely empty but we have data elsewhere // Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty { if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("WARNING INTEGRITY: Primary storage empty but backups exist") logWarning("WARNING INTEGRITY: Primary storage empty but backups exist")
return false return false
} }
@@ -874,7 +873,7 @@ class ImageManager {
for: .applicationSupportDirectory, in: .userDomainMask for: .applicationSupportDirectory, in: .userDomainMask
).first ).first
else { else {
print("ERROR: Could not access Application Support directory") logError("ERROR: Could not access Application Support directory")
return nil return nil
} }
@@ -908,13 +907,13 @@ class ImageManager {
} }
} }
} catch { } catch {
print("ERROR: Error scanning for previous Application Support directories: \(error)") logError("ERROR: Error scanning for previous Application Support directories: \(error)")
} }
return nil return nil
} }
private func migratePreviousAppSupportImages(from sourceDirectory: URL) { private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("Migrating images from previous Application Support directory") logInfo("Migrating images from previous Application Support directory")
do { do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path) let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
@@ -937,18 +936,33 @@ class ImageManager {
// Create backup // Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath) try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)") logInfo("Migrated: \(fileName)")
} catch { } catch {
print("ERROR: Failed to migrate \(fileName): \(error)") logError("ERROR: Failed to migrate \(fileName): \(error)")
} }
} }
} }
print("Completed migration from previous Application Support directory") logInfo("Completed migration from previous Application Support directory")
} catch { } catch {
print("ERROR: Failed to migrate from previous Application Support: \(error)") logError("ERROR: Failed to migrate from previous Application Support: \(error)")
} }
} }
private func logInfo(_ message: String) {
AppLogger.info(message, tag: logTag)
}
private func logWarning(_ message: String) {
AppLogger.warning(message, tag: logTag)
}
private func logError(_ message: String) {
AppLogger.error(message, tag: logTag)
}
private func logDebug(_ message: String) {
AppLogger.debug(message, tag: logTag)
}
} }

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

@@ -4,6 +4,8 @@ import zlib
struct ZipUtils { struct ZipUtils {
private static let logTag = "ZipUtils"
private static let DATA_JSON_FILENAME = "data.json" private static let DATA_JSON_FILENAME = "data.json"
private static let IMAGES_DIR_NAME = "images" private static let IMAGES_DIR_NAME = "images"
private static let METADATA_FILENAME = "metadata.txt" private static let METADATA_FILENAME = "metadata.txt"
@@ -49,7 +51,7 @@ struct ZipUtils {
) )
// Process images in batches for better performance // Process images in batches for better performance
print("Processing \(referencedImagePaths.count) images for export") logInfo("Processing \(referencedImagePaths.count) images for export")
var successfulImages = 0 var successfulImages = 0
let batchSize = 10 let batchSize = 10
let sortedPaths = Array(referencedImagePaths).sorted() let sortedPaths = Array(referencedImagePaths).sorted()
@@ -59,7 +61,7 @@ struct ZipUtils {
for (index, imagePath) in sortedPaths.enumerated() { for (index, imagePath) in sortedPaths.enumerated() {
if index % batchSize == 0 { if index % batchSize == 0 {
print("Processing images \(index)/\(sortedPaths.count)") logInfo("Processing images \(index)/\(sortedPaths.count)")
} }
let imageURL = URL(fileURLWithPath: imagePath) let imageURL = URL(fileURLWithPath: imagePath)
@@ -83,11 +85,11 @@ struct ZipUtils {
successfulImages += 1 successfulImages += 1
} }
} catch { } catch {
print("Failed to read image: \(imageName)") logWarning("Failed to read image: \(imageName)")
} }
} }
print("Export: included \(successfulImages)/\(referencedImagePaths.count) images") logInfo("Export: included \(successfulImages)/\(referencedImagePaths.count) images")
// Build central directory // Build central directory
centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry
@@ -114,7 +116,7 @@ struct ZipUtils {
} }
static func extractImportZip(data: Data) throws -> ImportResult { static func extractImportZip(data: Data) throws -> ImportResult {
print("Starting ZIP extraction - data size: \(data.count) bytes") logInfo("Starting ZIP extraction - data size: \(data.count) bytes")
return try extractUsingCustomParser(data: data) return try extractUsingCustomParser(data: data)
} }
@@ -127,10 +129,10 @@ struct ZipUtils {
let zipEntries: [ZipEntry] let zipEntries: [ZipEntry]
do { do {
zipEntries = try parseZipFile(data: data) zipEntries = try parseZipFile(data: data)
print("Successfully parsed ZIP file with \(zipEntries.count) entries") logInfo("Successfully parsed ZIP file with \(zipEntries.count) entries")
} catch { } catch {
print("Failed to parse ZIP file: \(error)") logError("Failed to parse ZIP file: \(error)")
print( logError(
"ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))" "ZIP data header: \(data.prefix(20).map { String(format: "%02X", $0) }.joined(separator: " "))"
) )
throw NSError( throw NSError(
@@ -142,24 +144,24 @@ struct ZipUtils {
) )
} }
print("Found \(zipEntries.count) entries in ZIP file:") logInfo("Found \(zipEntries.count) entries in ZIP file:")
for entry in zipEntries { for entry in zipEntries {
print(" - \(entry.filename) (size: \(entry.data.count) bytes)") logInfo(" - \(entry.filename) (size: \(entry.data.count) bytes)")
} }
for entry in zipEntries { for entry in zipEntries {
switch entry.filename { switch entry.filename {
case METADATA_FILENAME: case METADATA_FILENAME:
metadataContent = String(data: entry.data, encoding: .utf8) ?? "" metadataContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found metadata: \(metadataContent.prefix(100))...") logInfo("Found metadata: \(metadataContent.prefix(100))...")
case DATA_JSON_FILENAME: case DATA_JSON_FILENAME:
jsonContent = String(data: entry.data, encoding: .utf8) ?? "" jsonContent = String(data: entry.data, encoding: .utf8) ?? ""
print("Found data.json with \(jsonContent.count) characters") logInfo("Found data.json with \(jsonContent.count) characters")
if jsonContent.isEmpty { if jsonContent.isEmpty {
print("WARNING: data.json is empty!") logWarning("WARNING: data.json is empty!")
} else { } else {
print("data.json preview: \(jsonContent.prefix(200))...") logInfo("data.json preview: \(jsonContent.prefix(200))...")
} }
default: default:
@@ -173,17 +175,17 @@ struct ZipUtils {
entry.data, filename: originalFilename) entry.data, filename: originalFilename)
importedImagePaths[originalFilename] = filename importedImagePaths[originalFilename] = filename
} catch { } catch {
print("Failed to import image \(originalFilename): \(error)") logError("Failed to import image \(originalFilename): \(error)")
} }
} }
} }
} }
guard !jsonContent.isEmpty else { guard !jsonContent.isEmpty else {
print("ERROR: data.json not found or empty") logError("ERROR: data.json not found or empty")
print("Available files in ZIP:") logInfo("Available files in ZIP:")
for entry in zipEntries { for entry in zipEntries {
print(" - \(entry.filename)") logInfo(" - \(entry.filename)")
} }
throw NSError( throw NSError(
domain: "ImportError", code: 1, domain: "ImportError", code: 1,
@@ -194,13 +196,25 @@ struct ZipUtils {
) )
} }
print("Import extraction completed: \(importedImagePaths.count) images processed") logInfo("Import extraction completed: \(importedImagePaths.count) images processed")
return ImportResult( return ImportResult(
jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths jsonData: jsonContent.data(using: .utf8) ?? Data(), imagePathMapping: importedImagePaths
) )
} }
private static func logInfo(_ message: String) {
AppLogger.info(message, tag: logTag)
}
private static func logWarning(_ message: String) {
AppLogger.warning(message, tag: logTag)
}
private static func logError(_ message: String) {
AppLogger.error(message, tag: logTag)
}
private static func createMetadata( private static func createMetadata(
exportData: ClimbDataBackup, exportData: ClimbDataBackup,
referencedImagePaths: Set<String> referencedImagePaths: Set<String>

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] = []
@@ -38,7 +40,6 @@ class ClimbingDataManager: ObservableObject {
let healthKitService = HealthKitService.shared let healthKitService = HealthKitService.shared
@Published var isSyncing = false @Published var isSyncing = false
private enum Keys { private enum Keys {
static let gyms = "ascently_gyms" static let gyms = "ascently_gyms"
static let problems = "ascently_problems" static let problems = "ascently_problems"
@@ -79,7 +80,7 @@ class ClimbingDataManager: ObservableObject {
let name: String let name: String
} }
init() { fileprivate init() {
_ = ImageManager.shared _ = ImageManager.shared
migrateFromOpenClimbIfNeeded() migrateFromOpenClimbIfNeeded()
loadAllData() loadAllData()
@@ -115,7 +116,8 @@ class ClimbingDataManager: ObservableObject {
return return
} }
print("Starting migration from OpenClimb to Ascently keys...") AppLogger.info(
"Starting migration from OpenClimb to Ascently keys...", tag: LogTag.climbingData)
var migrationCount = 0 var migrationCount = 0
// Migrate each data type if it exists in old format but not in new format // Migrate each data type if it exists in old format but not in new format
@@ -135,7 +137,7 @@ class ClimbingDataManager: ObservableObject {
userDefaults.set(oldData, forKey: newKey) userDefaults.set(oldData, forKey: newKey)
userDefaults.removeObject(forKey: oldKey) userDefaults.removeObject(forKey: oldKey)
migrationCount += 1 migrationCount += 1
print("Migrated: \(oldKey)\(newKey)") AppLogger.info("Migrated: \(oldKey)\(newKey)", tag: LogTag.climbingData)
} }
} }
@@ -147,7 +149,8 @@ class ClimbingDataManager: ObservableObject {
{ {
sharedDefaults.set(oldData, forKey: newKey) sharedDefaults.set(oldData, forKey: newKey)
sharedDefaults.removeObject(forKey: oldKey) sharedDefaults.removeObject(forKey: oldKey)
print("✅ Migrated shared: \(oldKey)\(newKey)") AppLogger.info(
"Migrated shared: \(oldKey)\(newKey)", tag: LogTag.climbingData)
} }
} }
} }
@@ -161,18 +164,19 @@ class ClimbingDataManager: ObservableObject {
userDefaults.set(lastModified, forKey: newDataStateKey) userDefaults.set(lastModified, forKey: newDataStateKey)
userDefaults.removeObject(forKey: legacyDataStateKey) userDefaults.removeObject(forKey: legacyDataStateKey)
migrationCount += 1 migrationCount += 1
print("Migrated data state timestamp") AppLogger.info("Migrated data state timestamp", tag: LogTag.climbingData)
} }
// Mark migration as completed // Mark migration as completed
userDefaults.set(true, forKey: migrationKey) userDefaults.set(true, forKey: migrationKey)
if migrationCount > 0 { if migrationCount > 0 {
print( AppLogger.info(
"Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently" "Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently",
tag: LogTag.climbingData
) )
} else { } else {
print("No OpenClimb data found to migrate") AppLogger.info("No OpenClimb data found to migrate", tag: LogTag.climbingData)
} }
} }
@@ -413,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)
@@ -428,29 +439,40 @@ 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(),
sessionId: newSession.id) sessionId: newSession.id)
} catch { } catch {
print("Failed to start HealthKit workout: \(error.localizedDescription)") AppLogger.error(
} "Failed to start HealthKit workout: \(error.localizedDescription)",
} 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
@@ -467,21 +489,20 @@ 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())
} catch { } catch {
print("Failed to end HealthKit workout: \(error.localizedDescription)") AppLogger.error(
} "Failed to end HealthKit workout: \(error.localizedDescription)",
} tag: LogTag.climbingData)
} }
} }
return completedSession
} }
func updateSession(_ session: ClimbSession) { func updateSession(_ session: ClimbSession) {
@@ -667,7 +688,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedAttempts.isEmpty { if !orphanedAttempts.isEmpty {
print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts") AppLogger.info(
"🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts",
tag: LogTag.climbingData)
// Track these as deleted to prevent sync from re-introducing them // Track these as deleted to prevent sync from re-introducing them
for attempt in orphanedAttempts { for attempt in orphanedAttempts {
@@ -693,14 +716,15 @@ class ClimbingDataManager: ObservableObject {
if seenAttempts.contains(key) { if seenAttempts.contains(key) {
duplicateIds.append(attempt.id) duplicateIds.append(attempt.id)
print("🧹 Found duplicate attempt: \(attempt.id)") AppLogger.info("🧹 Found duplicate attempt: \(attempt.id)", tag: LogTag.climbingData)
} else { } else {
seenAttempts.insert(key) seenAttempts.insert(key)
} }
} }
if !duplicateIds.isEmpty { if !duplicateIds.isEmpty {
print("🧹 Removing \(duplicateIds.count) duplicate attempts") AppLogger.info(
"🧹 Removing \(duplicateIds.count) duplicate attempts", tag: LogTag.climbingData)
// Track duplicates as deleted // Track duplicates as deleted
for attemptId in duplicateIds { for attemptId in duplicateIds {
@@ -714,8 +738,9 @@ class ClimbingDataManager: ObservableObject {
if initialAttemptCount != attempts.count { if initialAttemptCount != attempts.count {
saveAttempts() saveAttempts()
let removedCount = initialAttemptCount - attempts.count let removedCount = initialAttemptCount - attempts.count
print( AppLogger.info(
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)" "Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)",
tag: LogTag.climbingData
) )
} }
@@ -725,7 +750,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedProblems.isEmpty { if !orphanedProblems.isEmpty {
print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems") AppLogger.info(
"🧹 Cleaning up \(orphanedProblems.count) orphaned problems",
tag: LogTag.climbingData)
for problem in orphanedProblems { for problem in orphanedProblems {
trackDeletion(itemId: problem.id.uuidString, itemType: "problem") trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
@@ -744,7 +771,9 @@ class ClimbingDataManager: ObservableObject {
} }
if !orphanedSessions.isEmpty { if !orphanedSessions.isEmpty {
print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions") AppLogger.info(
"🧹 Cleaning up \(orphanedSessions.count) orphaned sessions",
tag: LogTag.climbingData)
for session in orphanedSessions { for session in orphanedSessions {
trackDeletion(itemId: session.id.uuidString, itemType: "session") trackDeletion(itemId: session.id.uuidString, itemType: "session")
@@ -844,19 +873,29 @@ class ClimbingDataManager: ObservableObject {
let problemsForImages = problems let problemsForImages = problems
// Move heavy I/O operations to background thread // Move heavy I/O operations to background thread
let logTag = LogTag.climbingData
let zipData = try await Task.detached(priority: .userInitiated) { let zipData = try await Task.detached(priority: .userInitiated) {
// Collect actual image paths from disk for the ZIP // Collect actual image paths from disk for the ZIP
let referencedImagePaths = await Self.collectReferencedImagePathsStatic( let imageSummary = Self.collectReferencedImagePathsStatic(
problems: problemsForImages, problems: problemsForImages,
imagesDirectory: imagesDirectory) imagesDirectory: imagesDirectory)
print("Starting export with \(referencedImagePaths.count) images") let referencedImagePaths = imageSummary.paths
await MainActor.run {
AppLogger.info(
"Starting export with \(referencedImagePaths.count) images (\(imageSummary.missingCount) missing)",
tag: logTag)
}
let zipData = try await ZipUtils.createExportZip( let zipData = try await ZipUtils.createExportZip(
exportData: exportData, exportData: exportData,
referencedImagePaths: referencedImagePaths referencedImagePaths: referencedImagePaths
) )
print("Export completed successfully") await MainActor.run {
AppLogger.info("Export completed successfully", tag: logTag)
}
return (zipData, referencedImagePaths.count) return (zipData, referencedImagePaths.count)
}.value }.value
@@ -865,7 +904,7 @@ class ClimbingDataManager: ObservableObject {
return zipData.0 return zipData.0
} catch { } catch {
let errorMessage = "Export failed: \(error.localizedDescription)" let errorMessage = "Export failed: \(error.localizedDescription)"
print("ERROR: \(errorMessage)") AppLogger.error("ERROR: \(errorMessage)", tag: LogTag.climbingData)
setError(errorMessage) setError(errorMessage)
return nil return nil
} }
@@ -894,16 +933,24 @@ class ClimbingDataManager: ObservableObject {
return Date() return Date()
} }
print("Raw JSON content preview:") AppLogger.debug("Raw JSON content preview:", tag: LogTag.climbingData)
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") AppLogger.debug(
String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...",
tag: LogTag.climbingData
)
let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
print("Successfully decoded import data:") AppLogger.info(
print("- Gyms: \(importData.gyms.count)") """
print("- Problems: \(importData.problems.count)") Successfully decoded import data:
print("- Sessions: \(importData.sessions.count)") - Gyms: \(importData.gyms.count)
print("- Attempts: \(importData.attempts.count)") - Problems: \(importData.problems.count)
- Sessions: \(importData.sessions.count)
- Attempts: \(importData.attempts.count)
""",
tag: LogTag.climbingData
)
try validateImportData(importData) try validateImportData(importData)
@@ -960,14 +1007,20 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager { extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> { private func collectReferencedImagePaths() -> Set<String> {
let imagesDirectory = ImageManager.shared.imagesDirectory.path let imagesDirectory = ImageManager.shared.imagesDirectory.path
return Self.collectReferencedImagePathsStatic( let result = Self.collectReferencedImagePathsStatic(
problems: problems, problems: problems,
imagesDirectory: imagesDirectory) imagesDirectory: imagesDirectory)
AppLogger.info(
"Export: Collected \(result.paths.count) images (\(result.missingCount) missing)",
tag: LogTag.climbingData)
return result.paths
} }
private static func collectReferencedImagePathsStatic( nonisolated private static func collectReferencedImagePathsStatic(
problems: [Problem], imagesDirectory: String problems: [Problem], imagesDirectory: String
) -> Set<String> { ) -> (paths: Set<String>, missingCount: Int) {
var imagePaths = Set<String>() var imagePaths = Set<String>()
var missingCount = 0 var missingCount = 0
@@ -988,8 +1041,7 @@ extension ClimbingDataManager {
} }
} }
print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)") return (imagePaths, missingCount)
return imagePaths
} }
private func updateProblemImagePaths( private func updateProblemImagePaths(
@@ -1030,11 +1082,14 @@ extension ClimbingDataManager {
} }
deterministicImagePaths.append(deterministicName) deterministicImagePaths.append(deterministicName)
print("Renamed imported image: \(tempFileName)\(deterministicName)") AppLogger.debug(
"Renamed imported image: \(tempFileName)\(deterministicName)",
tag: LogTag.climbingData)
} }
} catch { } catch {
print( AppLogger.error(
"Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)" "Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)",
tag: LogTag.climbingData
) )
deterministicImagePaths.append(tempFileName) deterministicImagePaths.append(tempFileName)
} }
@@ -1078,7 +1133,8 @@ extension ClimbingDataManager {
if needsUpdate { if needsUpdate {
problems = updatedProblems problems = updatedProblems
saveProblems() saveProblems()
print("Migrated image paths for \(problems.count) problems") AppLogger.info(
"Migrated image paths for \(problems.count) problems", tag: LogTag.climbingData)
} }
} }
@@ -1089,8 +1145,9 @@ extension ClimbingDataManager {
// Log storage information for debugging // Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo() let info = await ImageManager.shared.getStorageInfo()
print( await AppLogger.debug(
"Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total",
tag: LogTag.climbingData
) )
}.value }.value
} }
@@ -1128,7 +1185,9 @@ extension ClimbingDataManager {
} }
if !orphanedFiles.isEmpty { if !orphanedFiles.isEmpty {
print("Cleaned up \(orphanedFiles.count) orphaned image files") AppLogger.info(
"Cleaned up \(orphanedFiles.count) orphaned image files",
tag: LogTag.climbingData)
} }
} }
} }
@@ -1145,7 +1204,7 @@ extension ClimbingDataManager {
} }
func forceImageRecovery() { func forceImageRecovery() {
print("User initiated force image recovery") AppLogger.info("User initiated force image recovery", tag: LogTag.climbingData)
ImageManager.shared.forceRecoveryMigration() ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery // Refresh the UI after recovery
@@ -1153,7 +1212,7 @@ extension ClimbingDataManager {
} }
func emergencyImageRestore() { func emergencyImageRestore() {
print("User initiated emergency image restore") AppLogger.info("User initiated emergency image restore", tag: LogTag.climbingData)
ImageManager.shared.emergencyImageRestore() ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore // Refresh the UI after restore
@@ -1179,15 +1238,15 @@ extension ClimbingDataManager {
} }
func testLiveActivity() { func testLiveActivity() {
print("🧪 Testing Live Activity functionality...") AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData)
// Check Live Activity availability // Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability() let status = LiveActivityManager.shared.checkLiveActivityAvailability()
print(status) AppLogger.info(status, tag: LogTag.climbingData)
// Test with dummy data if we have a gym // Test with dummy data if we have a gym
guard let testGym = gyms.first else { guard let testGym = gyms.first else {
print("ERROR: No gyms available for testing") AppLogger.error("No gyms available for testing", tag: LogTag.climbingData)
return return
} }
@@ -1218,15 +1277,18 @@ extension ClimbingDataManager {
// Only restart if session is actually active // Only restart if session is actually active
guard activeSession.status == .active else { guard activeSession.status == .active else {
print( AppLogger.warning(
"WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity" "Session exists but is not active (status: \(activeSession.status)), ending Live Activity",
tag: LogTag.climbingData
) )
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
return return
} }
if let gym = gym(withId: activeSession.gymId) { if let gym = gym(withId: activeSession.gymId) {
print("Checking Live Activity for active session at \(gym.name)") AppLogger.info(
"Checking Live Activity for active session at \(gym.name)", tag: LogTag.climbingData
)
// First cleanup any dismissed activities // First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities() await LiveActivityManager.shared.cleanupDismissedActivities()
@@ -1241,7 +1303,9 @@ extension ClimbingDataManager {
/// Call this when app becomes active to check for Live Activity restart /// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() { func onAppBecomeActive() {
print("App became active - checking Live Activity status") let logTag = "ClimbingData"
AppLogger.info(
"App became active - checking Live Activity status", tag: logTag)
Task { Task {
await checkAndRestartLiveActivity() await checkAndRestartLiveActivity()
} }
@@ -1249,35 +1313,46 @@ extension ClimbingDataManager {
/// Call this when app enters background to update Live Activity /// Call this when app enters background to update Live Activity
func onAppEnterBackground() { func onAppEnterBackground() {
print("App entering background - updating Live Activity if needed") let logTag = "ClimbingData"
AppLogger.info(
"App entering background - updating Live Activity if needed", tag: logTag)
Task { Task {
await updateLiveActivityData() await updateLiveActivityData()
} }
} }
/// Setup notifications for Live Activity events /// Setup notifications for Live Activity events
private func setupLiveActivityNotifications() { nonisolated private func setupLiveActivityNotifications() {
let notificationName = Notification.Name("liveActivityDismissed")
let logTag = "ClimbingData"
liveActivityObserver = NotificationCenter.default.addObserver( liveActivityObserver = NotificationCenter.default.addObserver(
forName: .liveActivityDismissed, forName: notificationName,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
print("🔔 Received Live Activity dismissed notification - attempting restart")
Task { @MainActor in Task { @MainActor in
AppLogger.info(
"Received Live Activity dismissed notification - attempting restart",
tag: logTag)
await self?.handleLiveActivityDismissed() await self?.handleLiveActivityDismissed()
} }
} }
} }
private func setupMigrationNotifications() { nonisolated private func setupMigrationNotifications() {
let logTag = "ClimbingData"
migrationObserver = NotificationCenter.default.addObserver( migrationObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("ImageMigrationCompleted"), forName: NSNotification.Name("ImageMigrationCompleted"),
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] notification in ) { [weak self] notification in
if let updateCount = notification.userInfo?["updateCount"] as? Int { if let updateCount = notification.userInfo?["updateCount"] as? Int {
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
Task { @MainActor in Task { @MainActor in
AppLogger.info(
"Image migration completed with \(updateCount) updates - reloading data",
tag: logTag)
self?.loadProblems() self?.loadProblems()
} }
} }
@@ -1293,7 +1368,9 @@ extension ClimbingDataManager {
return return
} }
print("Attempting to restart dismissed Live Activity for \(gym.name)") AppLogger.info(
"Attempting to restart dismissed Live Activity for \(gym.name)",
tag: LogTag.climbingData)
// Wait a bit before restarting to avoid frequency limits // Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
@@ -1333,11 +1410,20 @@ extension ClimbingDataManager {
activeSession.status == .active, activeSession.status == .active,
let gym = gym(withId: activeSession.gymId) let gym = gym(withId: activeSession.gymId)
else { else {
print("WARNING: Live Activity update skipped - no active session or gym") AppLogger.warning(
"Live Activity update skipped - no active session or gym",
tag: LogTag.climbingData
)
if let session = activeSession { if let session = activeSession {
print(" Session ID: \(session.id)") AppLogger.debug(
print(" Session Status: \(session.status)") """
print(" Gym ID: \(session.gymId)") Skipped session details:
Session ID: \(session.id)
Session Status: \(session.status)
Gym ID: \(session.gymId)
""",
tag: LogTag.climbingData
)
} }
return return
} }
@@ -1357,14 +1443,17 @@ extension ClimbingDataManager {
elapsedInterval = 0 elapsedInterval = 0
} }
print("Live Activity Update Debug:") AppLogger.debug(
print(" Session ID: \(activeSession.id)") """
print(" Gym: \(gym.name)") Live Activity Update Debug:
print(" Total attempts in session: \(totalAttempts)") Session ID: \(activeSession.id)
print(" Completed problems: \(completedProblems)") Gym: \(gym.name)
print(" Elapsed time: \(elapsedInterval) seconds") Total attempts in session: \(totalAttempts)
print( Completed problems: \(completedProblems)
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })" Elapsed time: \(elapsedInterval) seconds
All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })
""",
tag: LogTag.climbingData
) )
Task { Task {

View File

@@ -8,6 +8,7 @@ extension Notification.Name {
@MainActor @MainActor
final class LiveActivityManager { final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private static let logTag = "LiveActivity"
private init() {} private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>? nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
@@ -30,11 +31,12 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive { if isStillActive {
print("Live Activity still running: \(currentActivity.id)") AppLogger.debug("Live Activity still running: \(currentActivity.id)", tag: Self.logTag)
return return
} else { } else {
print( AppLogger.warning(
"WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference" "Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference",
tag: Self.logTag
) )
self.currentActivity = nil self.currentActivity = nil
} }
@@ -43,18 +45,18 @@ final class LiveActivityManager {
// Check if there are ANY active Live Activities for this session // Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first { if let existingActivity = existingActivities.first {
print("Found existing Live Activity: \(existingActivity.id), using it") AppLogger.info("Found existing Live Activity: \(existingActivity.id), using it", tag: Self.logTag)
self.currentActivity = existingActivity self.currentActivity = existingActivity
return return
} }
print("No Live Activity found, restarting for existing session") AppLogger.info("No Live Activity found, restarting for existing session", tag: Self.logTag)
await startLiveActivity(for: activeSession, gymName: gymName) await startLiveActivity(for: activeSession, gymName: gymName)
} }
/// Call this when a ClimbSession starts to begin a Live Activity /// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async { func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("Starting Live Activity for gym: \(gymName)") AppLogger.info("Starting Live Activity for gym: \(gymName)", tag: Self.logTag)
await endLiveActivity() await endLiveActivity()
@@ -80,18 +82,26 @@ final class LiveActivityManager {
pushType: nil pushType: nil
) )
self.currentActivity = activity self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)") AppLogger.info("Live Activity started successfully: \(activity.id)", tag: Self.logTag)
} catch { } catch {
print("ERROR: Failed to start live activity: \(error)") AppLogger.error(
print("Error details: \(error.localizedDescription)") """
Failed to start live activity: \(error)
Details: \(error.localizedDescription)
""",
tag: Self.logTag
)
// Check specific error types // Check specific error types
if error.localizedDescription.contains("authorization") { if error.localizedDescription.contains("authorization") {
print("Authorization error - check Live Activity permissions in Settings") AppLogger.warning(
"Authorization error - check Live Activity permissions in Settings",
tag: Self.logTag
)
} else if error.localizedDescription.contains("content") { } else if error.localizedDescription.contains("content") {
print("Content error - check ActivityAttributes structure") AppLogger.warning("Content error - check ActivityAttributes structure", tag: Self.logTag)
} else if error.localizedDescription.contains("frequencyLimited") { } else if error.localizedDescription.contains("frequencyLimited") {
print("Frequency limited - too many Live Activities started recently") AppLogger.warning("Frequency limited - too many Live Activities started recently", tag: Self.logTag)
} }
} }
} }
@@ -100,7 +110,7 @@ final class LiveActivityManager {
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{ {
guard let currentActivity = currentActivity else { guard let currentActivity = currentActivity else {
print("WARNING: No current activity to update") AppLogger.warning("No current activity to update", tag: Self.logTag)
return return
} }
@@ -109,15 +119,17 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print( AppLogger.warning(
"WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference" "Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference",
tag: Self.logTag
) )
self.currentActivity = nil self.currentActivity = nil
return return
} }
print( AppLogger.debug(
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" "Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)",
tag: Self.logTag
) )
let updatedContentState = SessionActivityAttributes.ContentState( let updatedContentState = SessionActivityAttributes.ContentState(
@@ -137,26 +149,26 @@ final class LiveActivityManager {
// First end the tracked activity if it exists // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("Ending tracked Live Activity: \(currentActivity.id)") AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag)
nonisolated(unsafe) let activity = currentActivity nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate) await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil self.currentActivity = nil
print("Tracked Live Activity ended successfully") AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag)
} }
// Force end ALL active activities of our type to ensure cleanup // Force end ALL active activities of our type to ensure cleanup
print("Checking for any remaining active activities...") AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag)
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty { if activities.isEmpty {
print("No additional activities found") AppLogger.debug("No additional activities found", tag: Self.logTag)
} else { } else {
print("Found \(activities.count) additional active activities, ending them...") AppLogger.info("Found \(activities.count) additional active activities, ending them...", tag: Self.logTag)
for activity in activities { for activity in activities {
print("Force ending activity: \(activity.id)") AppLogger.debug("Force ending activity: \(activity.id)", tag: Self.logTag)
await activity.end(nil, dismissalPolicy: .immediate) await activity.end(nil, dismissalPolicy: .immediate)
} }
print("All Live Activities ended successfully") AppLogger.info("All Live Activities ended successfully", tag: Self.logTag)
} }
} }
@@ -174,7 +186,7 @@ final class LiveActivityManager {
• All Active Activities: \(allActivities.count) • All Active Activities: \(allActivities.count)
""" """
print(message) AppLogger.info(message, tag: Self.logTag)
return message return message
} }
@@ -185,7 +197,7 @@ final class LiveActivityManager {
if let currentActivity = currentActivity { if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("Cleaning up dismissed Live Activity: \(currentActivity.id)") AppLogger.info("Cleaning up dismissed Live Activity: \(currentActivity.id)", tag: Self.logTag)
self.currentActivity = nil self.currentActivity = nil
} }
} }
@@ -195,7 +207,7 @@ final class LiveActivityManager {
func startHealthChecks() { func startHealthChecks() {
stopHealthChecks() // Stop any existing timer stopHealthChecks() // Stop any existing timer
print("🩺 Starting Live Activity health checks") AppLogger.debug("🩺 Starting Live Activity health checks", tag: Self.logTag)
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -208,7 +220,7 @@ final class LiveActivityManager {
func stopHealthChecks() { func stopHealthChecks() {
healthCheckTimer?.invalidate() healthCheckTimer?.invalidate()
healthCheckTimer = nil healthCheckTimer = nil
print("Stopped Live Activity health checks") AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag)
} }
/// Perform a health check on the current Live Activity /// Perform a health check on the current Live Activity
@@ -221,14 +233,14 @@ final class LiveActivityManager {
// Only perform health check if it's been at least 25 seconds // Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return } guard timeSinceLastCheck >= 25 else { return }
print("🩺 Performing Live Activity health check") AppLogger.debug("🩺 Performing Live Activity health check", tag: Self.logTag)
lastHealthCheck = now lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive { if !isStillActive {
print("Health check failed - Live Activity was dismissed") AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag)
self.currentActivity = nil self.currentActivity = nil
// Notify that we need to restart // Notify that we need to restart
@@ -237,7 +249,7 @@ final class LiveActivityManager {
object: nil object: nil
) )
} else { } else {
print("Live Activity health check passed") AppLogger.debug("Live Activity health check passed", tag: Self.logTag)
} }
} }

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

@@ -196,7 +196,7 @@ struct LiveActivityDebugView: View {
} }
isTestRunning = true isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...") appendDebugOutput("Starting Live Activity test...")
Task { Task {
defer { defer {

View File

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

View File

@@ -9,6 +9,7 @@ enum SheetType {
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var activeSheet: SheetType? @State private var activeSheet: SheetType?
var body: some View { var body: some View {
@@ -20,6 +21,8 @@ struct SettingsView: View {
HealthKitSection() HealthKitSection()
.environmentObject(dataManager.healthKitService) .environmentObject(dataManager.healthKitService)
AppearanceSection()
DataManagementSection( DataManagementSection(
activeSheet: $activeSheet activeSheet: $activeSheet
) )
@@ -75,8 +78,90 @@ extension SheetType: Identifiable {
} }
} }
struct AppearanceSection: View {
@EnvironmentObject var themeManager: ThemeManager
let columns = [
GridItem(.adaptive(minimum: 44))
]
var body: some View {
Section("Appearance") {
VStack(alignment: .leading, spacing: 12) {
Text("Accent Color")
.font(.caption)
.foregroundColor(.secondary)
.textCase(.uppercase)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(ThemeManager.presetColors, id: \.self) { color in
Circle()
.fill(color)
.frame(width: 44, height: 44)
.overlay(
ZStack {
if isSelected(color) {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.white)
.shadow(radius: 1)
}
}
)
.onTapGesture {
withAnimation {
themeManager.accentColor = color
}
}
.accessibilityLabel(colorDescription(for: color))
.accessibilityAddTraits(isSelected(color) ? .isSelected : [])
}
}
.padding(.vertical, 8)
}
if !isSelected(.blue) {
Button("Reset to Default") {
withAnimation {
themeManager.resetToDefault()
}
}
.foregroundColor(.red)
}
}
}
private func isSelected(_ color: Color) -> Bool {
// Compare using UIColor to handle different Color initializers
let selectedUIColor = UIColor(themeManager.accentColor)
let targetUIColor = UIColor(color)
// Simple equality check might fail for some system colors, so we check components if needed
// But usually UIColor equality is robust enough for system colors
return selectedUIColor == targetUIColor
}
private func colorDescription(for color: Color) -> String {
switch color {
case .blue: return "Blue"
case .purple: return "Purple"
case .pink: return "Pink"
case .red: return "Red"
case .orange: return "Orange"
case .green: return "Green"
case .teal: return "Teal"
case .indigo: return "Indigo"
case .mint: return "Mint"
case Color(uiColor: .systemBrown): return "Brown"
case Color(uiColor: .systemCyan): return "Cyan"
default: return "Color"
}
}
}
struct DataManagementSection: View { struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Binding var activeSheet: SheetType? @Binding var activeSheet: SheetType?
@State private var showingResetAlert = false @State private var showingResetAlert = false
@State private var isExporting = false @State private var isExporting = false
@@ -84,6 +169,8 @@ struct DataManagementSection: View {
@State private var isDeletingImages = false @State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false @State private var showingDeleteImagesAlert = false
private static let logTag = "DataManagementSection"
var body: some View { var body: some View {
Section("Data Management") { Section("Data Management") {
// Export Data // Export Data
@@ -98,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()
@@ -217,13 +304,14 @@ struct DataManagementSection: View {
try fileManager.removeItem(at: imageFile) try fileManager.removeItem(at: imageFile)
deletedCount += 1 deletedCount += 1
} catch { } catch {
print("Failed to delete image: \(imageFile.lastPathComponent)") AppLogger.error(
"Failed to delete image: \(imageFile.lastPathComponent)", tag: Self.logTag)
} }
} }
print("Deleted \(deletedCount) image files") AppLogger.info("Deleted \(deletedCount) image files", tag: Self.logTag)
} catch { } catch {
print("Failed to access images directory: \(error)") AppLogger.error("Failed to access images directory: \(error)", tag: Self.logTag)
} }
// Delete all images from backup directory // Delete all images from backup directory
@@ -235,7 +323,7 @@ struct DataManagementSection: View {
try? fileManager.removeItem(at: backupFile) try? fileManager.removeItem(at: backupFile)
} }
} catch { } catch {
print("Failed to access backup directory: \(error)") AppLogger.error("Failed to access backup directory: \(error)", tag: Self.logTag)
} }
// Clear image paths from all problems // Clear image paths from all problems
@@ -250,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"
} }
@@ -260,23 +349,9 @@ struct AppInfoSection: View {
var body: some View { var body: some View {
Section("App Information") { Section("App Information") {
HStack {
Image("AppLogo")
.resizable()
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Ascently")
.font(.headline)
Text("Track your climbing progress")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
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))")
@@ -289,18 +364,21 @@ 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
private static let logTag = "ExportDataView"
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 30) { VStack(spacing: 30) {
if isCreatingFile { if isCreatingFile {
// Loading state - more prominent // Loading state
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)
@@ -339,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)
@@ -380,6 +458,7 @@ struct ExportDataView: View {
} }
private func createTempFile() { private func createTempFile() {
let logTag = Self.logTag // Capture before entering background queue
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
do { do {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
@@ -394,7 +473,9 @@ struct ExportDataView: View {
for: .documentDirectory, in: .userDomainMask for: .documentDirectory, in: .userDomainMask
).first ).first
else { else {
print("Could not access Documents directory") Task { @MainActor in
AppLogger.error("Could not access Documents directory", tag: logTag)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
@@ -410,7 +491,9 @@ struct ExportDataView: View {
self.isCreatingFile = false self.isCreatingFile = false
} }
} catch { } catch {
print("Failed to create export file: \(error)") Task { @MainActor in
AppLogger.error("Failed to create export file: \(error)", tag: logTag)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
@@ -420,10 +503,12 @@ struct ExportDataView: View {
private func cleanupTempFile() { private func cleanupTempFile() {
if let fileURL = tempFileURL { if let fileURL = tempFileURL {
let logTag = Self.logTag // Capture before entering async closure
// Clean up after a delay to ensure sharing is complete // Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL) try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)") AppLogger.debug(
"Cleaned up export file: \(fileURL.lastPathComponent)", tag: logTag)
} }
} }
} }
@@ -432,9 +517,12 @@ 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
private static let logTag = "SyncSection"
var body: some View { var body: some View {
Section("Sync") { Section("Sync") {
// Sync Status // Sync Status
@@ -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")
@@ -579,11 +667,14 @@ struct SyncSection: View {
} }
private func performSync() { private func performSync() {
let logTag = Self.logTag // Capture before entering async context
Task { Task {
do { do {
try await syncService.syncWithServer(dataManager: dataManager) try await syncService.syncWithServer(dataManager: dataManager)
} catch { } catch {
print("Sync failed: \(error)") await MainActor.run {
AppLogger.error("Sync failed: \(error)", tag: logTag)
}
} }
} }
} }
@@ -591,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 = ""
@@ -641,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 {
@@ -702,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)
@@ -742,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 {