Compare commits

...

8 Commits

Author SHA1 Message Date
a6508da413 iOS 2.4.2 - Fixed Swipe Action Colours 2025-12-07 01:43:19 -07:00
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
38 changed files with 3100 additions and 2636 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 = 47 versionCode = 48
versionName = "2.3.1" versionName = "2.4.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

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 androidx.annotation.RequiresPermission
import androidx.core.content.edit import androidx.core.content.edit
import com.atridad.ascently.data.format.BackupAttempt
import com.atridad.ascently.utils.AppLogger
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,93 +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) {
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return
}
if (!isNetworkAvailable()) {
_syncError.value = "No internet connection."
AppLogger.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()
// Update last sync time from shared prefs (provider updates it)
val hasLocalData = _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
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()
// 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) {
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()
_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
@@ -244,550 +114,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
AppLogger.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
}
AppLogger.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")
}
}
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}"
}
// 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}" }
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) }
} 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
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: 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")
}
}
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) {
@@ -812,30 +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

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

View File

@@ -411,11 +411,13 @@ class ClimbViewModel(
} }
// Sync-related methods // Sync-related methods
suspend fun performManualSync() { fun performManualSync() {
try { viewModelScope.launch {
syncService.syncWithServer() try {
} catch (e: Exception) { syncService.syncWithServer()
setError("Sync failed: ${e.message}") } catch (e: Exception) {
setError("Sync failed: ${e.message}")
}
} }
} }

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

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

147
docs/pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -487,7 +487,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.3.0; MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -535,7 +535,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.3.0; MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -613,7 +613,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.3.0; MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 36;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -643,7 +643,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.3.0; MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct SessionDetailView: View { struct SessionDetailView: View {
let sessionId: UUID let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false @State private var showingDeleteAlert = false
@State private var showingAddAttempt = false @State private var showingAddAttempt = false
@@ -35,26 +36,92 @@ struct SessionDetailView: View {
} }
var body: some View { var body: some View {
ScrollView { List {
LazyVStack(spacing: 20) { if let session = session, let gym = gym {
if let session = session, let gym = gym { Section {
SessionHeaderCard( SessionHeaderCard(
session: session, gym: gym, stats: sessionStats) session: session, gym: gym, stats: sessionStats)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 8)
SessionStatsCard(stats: sessionStats) SessionStatsCard(stats: sessionStats)
.listRowInsets(EdgeInsets())
AttemptsSection( .listRowBackground(Color.clear)
attemptsWithProblems: attemptsWithProblems, .listRowSeparator(.hidden)
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} else {
Text("Session not found")
.foregroundColor(.secondary)
} }
}
.padding()
}
Section {
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
.accessibilityLabel("Delete attempt")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
.accessibilityLabel("Edit attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
} header: {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.primary)
.textCase(nil)
.padding(.bottom, 8)
.padding(.top, 16)
}
} else {
Text("Session not found")
.foregroundColor(.secondary)
}
}
.listStyle(.plain)
.navigationTitle("Session Details") .navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -112,9 +179,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 +229,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 +240,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 +268,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 +281,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 +332,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 +349,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 +366,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 +395,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?
@@ -62,6 +64,7 @@ struct GymsList: View {
} label: { } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red)
Button { Button {
gymToEdit = gym gymToEdit = gym
@@ -71,7 +74,7 @@ struct GymsList: View {
Text("Edit") Text("Edit")
} }
} }
.tint(.blue) .tint(.indigo)
} }
} }
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) { .alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
@@ -98,6 +101,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 +137,9 @@ struct GymRow: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1)) .fill(themeManager.accentColor.opacity(0.1))
) )
.foregroundColor(.blue) .foregroundColor(themeManager.accentColor)
} }
} }
} }

View File

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

@@ -142,6 +142,7 @@ struct SessionsList: View {
} label: { } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red)
} }
} }
} header: { } header: {

View File

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