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"
minSdk = 31
targetSdk = 36
versionCode = 47
versionName = "2.3.1"
versionCode = 48
versionName = "2.4.0"
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.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.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.state.DataStateManager
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 com.atridad.ascently.utils.AppLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -31,43 +13,21 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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) {
private val dataStateManager = DataStateManager(context)
private val syncMutex = Mutex()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val syncMutex = Mutex()
companion object {
private const val TAG = "SyncService"
}
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
}
// Currently we only support one provider, but this allows for future expansion
private val provider: SyncProvider = AscentlySyncProvider(context, repository)
// State
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)
val syncError: StateFlow<String?> = _syncError.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
// Delegate to provider
val isConnected: StateFlow<Boolean> = provider.isConnected
val isConfiguredFlow: StateFlow<Boolean> = provider.isConfigured
private val _isTesting = MutableStateFlow(false)
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)
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
private var isOfflineMode = false
// Debounced sync properties
private var syncJob: Job? = null
private var pendingChanges = false
private val syncDebounceDelay = 2000L // 2 seconds
// Configuration keys
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
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 AUTO_SYNC_ENABLED = "auto_sync_enabled"
const val OFFLINE_MODE = "offline_mode"
}
init {
loadInitialState()
updateConfiguredState()
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
}
private fun loadInitialState() {
_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)
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
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.serverUrl ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
(provider as? AscentlySyncProvider)?.serverUrl = value
}
var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
get() = (provider as? AscentlySyncProvider)?.authToken ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
(provider as? AscentlySyncProvider)?.authToken = value
}
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) }
}
@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() {
if (isOfflineMode) {
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) {
if (!isConfiguredFlow.value) {
throw SyncException.NotConfigured
}
if (!_isConnected.value) {
throw SyncException.NotConnected
}
syncMutex.withLock {
_isSyncing.value = true
_syncError.value = null
try {
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()
// 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) }
provider.sync()
// Update last sync time from shared prefs (provider updates it)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
} catch (e: Exception) {
_syncError.value = e.message
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() {
if (!_isConfigured.value) {
_isConnected.value = false
_syncError.value = "Server URL or Auth Token is not set."
return
}
_isTesting.value = true
_syncError.value = null
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) {
_syncError.value = "Connection failed. Check URL and token."
}
provider.testConnection()
} catch (e: Exception) {
_isConnected.value = false
_syncError.value = "Connection error: ${e.message}"
throw e
} finally {
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false
}
}
fun triggerAutoSync() {
if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
if (!isConfiguredFlow.value || !isConnected.value || !_isAutoSyncEnabled.value) {
return
}
if (_isSyncing.value) {
@@ -812,30 +153,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
fun clearConfiguration() {
syncJob?.cancel()
serverUrl = ""
authToken = ""
provider.disconnect()
setAutoSyncEnabled(true)
_lastSyncTime.value = null
_isConnected.value = false
_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
TextButton(
onClick = {
coroutineScope.launch {
viewModel.performManualSync()
}
viewModel.performManualSync()
},
enabled = isConnected && !isSyncing
) {

View File

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

View File

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

View File

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

147
docs/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import AppIntents
/// Provides a curated list of the most useful Ascently shortcuts for Siri and the Shortcuts app.
/// Surfaces intents that users can trigger hands-free to manage their climbing sessions.
/// Defines the App Shortcuts available in the Shortcuts app.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
@@ -11,23 +10,15 @@ struct AscentlyShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: StartLastGymSessionIntent(),
intent: ToggleSessionIntent(),
phrases: [
"Start my climb in \(.applicationName)",
"Begin my last gym session in \(.applicationName)",
"Toggle climb in \(.applicationName)",
"Start or stop climb in \(.applicationName)",
"Climb toggle in \(.applicationName)",
],
shortTitle: "Start Climb",
shortTitle: "Toggle Session",
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
}
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
/// Controller for handling session operations from App Intents.
@MainActor
final class SessionIntentController {
@@ -39,9 +39,9 @@ final class SessionIntentController {
/// Starts a new session using the most recently visited gym.
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 {
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 {
@@ -89,7 +89,23 @@ final class SessionIntentController {
}
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)")
}
/// 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 {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var themeManager = ThemeManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(themeManager)
.tint(themeManager.accentColor)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct CalendarView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
let sessions: [ClimbSession]
@Binding var selectedMonth: Date
@Binding var selectedDate: Date?
@@ -68,7 +69,7 @@ struct CalendarView: View {
Image(systemName: "chevron.left")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
.frame(width: 44, height: 44)
@@ -84,7 +85,7 @@ struct CalendarView: View {
Image(systemName: "chevron.right")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
.frame(width: 44, height: 44)
}
@@ -97,10 +98,10 @@ struct CalendarView: View {
Text("Today")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
.foregroundColor(themeManager.contrastingTextColor)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.blue)
.background(themeManager.accentColor)
.clipShape(Capsule())
}
}
@@ -209,6 +210,7 @@ struct CalendarDayCell: View {
let isToday: Bool
let isInCurrentMonth: Bool
let onTap: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var dayNumber: String {
let formatter = DateFormatter()
@@ -224,9 +226,9 @@ struct CalendarDayCell: View {
.fontWeight(sessions.isEmpty ? .regular : .medium)
.foregroundColor(
isSelected
? .white
? themeManager.contrastingTextColor
: isToday
? .blue
? themeManager.accentColor
: !isInCurrentMonth
? .secondary.opacity(0.3)
: sessions.isEmpty ? .secondary : .primary
@@ -234,7 +236,7 @@ struct CalendarDayCell: View {
if !sessions.isEmpty {
Circle()
.fill(isSelected ? .white : .blue)
.fill(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
.frame(width: 4, height: 4)
} else {
Spacer()
@@ -247,13 +249,13 @@ struct CalendarDayCell: View {
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear
isSelected ? themeManager.accentColor : isToday ? themeManager.accentColor.opacity(0.1) : Color.clear
)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.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 {
let gymId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@@ -108,6 +109,7 @@ struct GymDetailView: View {
struct GymHeaderCard: View {
let gym: Gym
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -145,9 +147,9 @@ struct GymHeaderCard: View {
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue.opacity(0.1))
.fill(themeManager.accentColor.opacity(0.1))
)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
}
.padding(.horizontal, 1)
@@ -318,8 +320,8 @@ struct ProblemRowCard: View {
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
)
}
}
@@ -371,8 +373,8 @@ struct SessionRowCard: View {
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
)
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct ProblemDetailView: View {
let problemId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingImageViewer = false
@@ -125,6 +126,7 @@ struct ProblemDetailView: View {
struct ProblemHeaderCard: View {
let problem: Problem
let gym: Gym
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -151,7 +153,7 @@ struct ProblemHeaderCard: View {
Text(problem.difficulty.grade)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text(problem.climbType.displayName)
.font(.subheadline)
@@ -178,9 +180,9 @@ struct ProblemHeaderCard: View {
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
.fill(themeManager.accentColor.opacity(0.1))
)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
}
.padding(.horizontal, 1)
@@ -223,6 +225,7 @@ struct ProgressSummaryCard: View {
let totalAttempts: Int
let successfulAttempts: Int
let firstSuccess: (date: Date, result: AttemptResult)?
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -251,7 +254,7 @@ struct ProgressSummaryCard: View {
"\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))"
)
.font(.subheadline)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
.padding(.top, 8)
}
@@ -396,7 +399,8 @@ struct AttemptHistoryCard: View {
.padding()
.background(
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 {
let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@@ -35,26 +36,92 @@ struct SessionDetailView: View {
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
List {
if let session = session, let gym = gym {
Section {
SessionHeaderCard(
session: session, gym: gym, stats: sessionStats)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.bottom, 8)
SessionStatsCard(stats: sessionStats)
AttemptsSection(
attemptsWithProblems: attemptsWithProblems,
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} else {
Text("Session not found")
.foregroundColor(.secondary)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.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")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -112,9 +179,9 @@ struct SessionDetailView: View {
Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus")
.font(.title2)
.foregroundColor(.white)
.foregroundColor(.white) // Keep white for contrast on colored button
.frame(width: 56, height: 56)
.background(Circle().fill(.blue))
.background(Circle().fill(themeManager.accentColor))
.shadow(radius: 4)
}
.padding()
@@ -162,6 +229,7 @@ struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -172,7 +240,7 @@ struct SessionHeaderCard: View {
Text(formatDate(session.date))
.font(.title2)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
if session.status == .active {
if let startTime = session.startTime {
@@ -200,12 +268,12 @@ struct SessionHeaderCard: View {
// Status indicator
HStack {
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")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(session.status == .active ? .green : .blue)
.foregroundColor(session.status == .active ? .green : themeManager.accentColor)
Spacer()
}
@@ -213,7 +281,7 @@ struct SessionHeaderCard: View {
.padding(.vertical, 6)
.background(
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()
@@ -264,13 +332,14 @@ struct SessionStatsCard: View {
struct StatItem: View {
let label: String
let value: String
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text(label)
.font(.caption)
@@ -280,85 +349,12 @@ struct StatItem: View {
}
}
struct AttemptsSection: View {
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)
}
}
}
}
// AttemptsSection removed as it is now integrated into the main List
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -370,7 +366,7 @@ struct AttemptCard: View {
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
if let location = problem.location {
Text(location)
@@ -399,9 +395,11 @@ struct AttemptCard: View {
}
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
.background(
RoundedRectangle(cornerRadius: 12)
.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 {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingAddGym = false
var body: some View {
@@ -19,7 +20,7 @@ struct GymsView: View {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
@@ -48,6 +49,7 @@ struct GymsView: View {
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var gymToDelete: Gym?
@State private var gymToEdit: Gym?
@@ -62,6 +64,7 @@ struct GymsList: View {
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
gymToEdit = gym
@@ -71,7 +74,7 @@ struct GymsList: View {
Text("Edit")
}
}
.tint(.blue)
.tint(.indigo)
}
}
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
@@ -98,6 +101,7 @@ struct GymsList: View {
struct GymRow: View {
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
private var problemCount: Int {
dataManager.problems(forGym: gym.id).count
@@ -133,9 +137,9 @@ struct GymRow: View {
.padding(.vertical, 4)
.background(
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 {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingAddProblem = false
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@State private var searchText = ""
@State private var showingSearch = false
@State private var showingFilters = false
@FocusState private var isSearchFocused: Bool
@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() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
@@ -70,61 +77,68 @@ struct ProblemsView: View {
var body: some View {
NavigationStack {
Group {
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)
.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 {
if cachedFilteredProblems.isEmpty {
VStack(spacing: 0) {
headerContent
EmptyProblemsView(
isEmpty: 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")
@@ -134,7 +148,7 @@ struct ProblemsView: View {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
@@ -162,7 +176,15 @@ struct ProblemsView: View {
}) {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.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 {
@@ -175,6 +197,32 @@ struct ProblemsView: View {
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
.sheet(isPresented: $showingFilters) {
FilterSheet(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.presentationDetents([.height(320)])
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
}
.onAppear {
updateFilteredProblems()
@@ -191,11 +239,57 @@ struct ProblemsView: View {
.onChange(of: selectedGym) {
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 {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
@@ -278,6 +372,7 @@ struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
Button(action: action) {
@@ -288,93 +383,21 @@ struct FilterChip: View {
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? .blue : .clear)
.stroke(.blue, lineWidth: 1)
.fill(isSelected ? themeManager.accentColor : .clear)
.stroke(themeManager.accentColor, lineWidth: 1)
)
.foregroundColor(isSelected ? .white : .blue)
.foregroundColor(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
}
.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 {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
private var gym: Gym? {
dataManager.gym(withId: problem.gymId)
@@ -407,7 +430,7 @@ struct ProblemRow: View {
if !problem.imagePaths.isEmpty {
Image(systemName: "photo")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
if isCompleted {
@@ -419,7 +442,7 @@ struct ProblemRow: View {
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
}
Text(problem.climbType.displayName)
@@ -444,9 +467,9 @@ struct ProblemRow: View {
.padding(.vertical, 2)
.background(
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 {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)

View File

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

View File

@@ -9,6 +9,7 @@ enum SheetType {
struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var activeSheet: SheetType?
var body: some View {
@@ -20,6 +21,8 @@ struct SettingsView: View {
HealthKitSection()
.environmentObject(dataManager.healthKitService)
AppearanceSection()
DataManagementSection(
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 {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
@@ -100,7 +185,7 @@ struct DataManagementSection: View {
.foregroundColor(.secondary)
} else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Export Data")
}
Spacer()
@@ -253,6 +338,7 @@ struct DataManagementSection: View {
}
struct AppInfoSection: View {
@EnvironmentObject var themeManager: ThemeManager
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
@@ -265,7 +351,7 @@ struct AppInfoSection: View {
Section("App Information") {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Version")
Spacer()
Text("\(appVersion) (\(buildNumber))")
@@ -278,6 +364,7 @@ struct AppInfoSection: View {
struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var themeManager: ThemeManager
@State private var tempFileURL: URL?
@State private var isCreatingFile = true
@@ -291,7 +378,7 @@ struct ExportDataView: View {
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
.tint(themeManager.accentColor)
Text("Preparing Your Export")
.font(.title2)
@@ -330,12 +417,12 @@ struct ExportDataView: View {
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.foregroundColor(themeManager.contrastingTextColor)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.fill(themeManager.accentColor)
)
}
.padding(.horizontal)
@@ -430,6 +517,7 @@ struct ExportDataView: View {
struct SyncSection: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
@@ -475,7 +563,7 @@ struct SyncSection: View {
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Configure Server")
Spacer()
Image(systemName: "chevron.right")
@@ -594,6 +682,7 @@ struct SyncSection: View {
struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var serverURL: String = ""
@State private var authToken: String = ""
@@ -644,7 +733,7 @@ struct SyncSettingsView: View {
.foregroundColor(.secondary)
} else {
Image(systemName: "network")
.foregroundColor(.blue)
.foregroundColor(themeManager.accentColor)
Text("Test Connection")
Spacer()
if syncService.isConnected {
@@ -705,6 +794,12 @@ struct SyncSettingsView: View {
syncService.serverURL = newURL
syncService.authToken = newToken
// Ensure provider type is set to server
if syncService.providerType != .server {
syncService.providerType = .server
}
dismiss()
}
.fontWeight(.semibold)
@@ -745,6 +840,13 @@ struct SyncSettingsView: View {
Task {
do {
// Ensure we are using the server provider
await MainActor.run {
if syncService.providerType != .server {
syncService.providerType = .server
}
}
// Temporarily set the values for testing
syncService.serverURL = testURL
syncService.authToken = testToken