Compare commits

...

15 Commits

Author SHA1 Message Date
6342bfed5c 2.3.1 - Dependency Updates, Better Live Notifications, and Calendar Fixes 2025-12-01 11:46:31 -07:00
869ca0fc0d Merge pull request '2.3.0 - Unified logging and app intents' (#6) from logging into main
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 6m59s
Ascently - Sync Deploy / build-and-push (push) Successful in 2m0s
Reviewed-on: #6
2025-11-21 04:01:43 +00:00
33562e9d16 Merge branch 'main' into logging
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 7m42s
2025-11-21 04:01:16 +00:00
a212f3f3b5 2.3.0 - Unified logging and app intents
All checks were successful
Ascently - Docs Deploy / build-and-push (pull_request) Successful in 8m4s
2025-11-20 21:00:00 -07:00
a99196b9ca Deps for docs 2025-11-19 15:04:47 -07:00
93fb7a41fb iOS 2.2.1 2025-11-18 12:59:26 -07:00
6d67ae6d81 Logging overhaul 2025-11-18 12:58:45 -07:00
071e47f95e Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m18s
2025-10-25 09:41:27 +00:00
c6c3e6084b Update docs/src/content/docs/privacy.md
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m19s
2025-10-25 09:33:33 +00:00
c2f95f2793 [Android] 2.2.1 - Better Widget 2025-10-21 10:22:31 -06:00
b7a3c98b2c [Android] 2.2.1 - Better Widget 2025-10-21 10:21:35 -06:00
fed9bab2ea Fixeds QR flashing
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m49s
2025-10-21 08:50:34 -06:00
862622b07b Fixed
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2025-10-20 00:03:35 -06:00
eba503eb5e Updated docs with QR Codes
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m26s
2025-10-19 23:55:30 -06:00
8c4a78ad50 2.2.0 - Final Builds
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m32s
2025-10-18 23:02:31 -06:00
56 changed files with 3829 additions and 2329 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 44
versionName = "2.2.0"
versionCode = 47
versionName = "2.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -38,7 +38,10 @@ android {
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }

View File

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

View File

@@ -3,7 +3,7 @@ package com.atridad.ascently.data.health
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.ascently.utils.AppLogger
import androidx.activity.result.contract.ActivityResultContract
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flow
class HealthConnectManager(private val context: Context) {
private val preferences: SharedPreferences =
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
context.getSharedPreferences("health_connect_prefs", Context.MODE_PRIVATE)
private val _isEnabled = MutableStateFlow(preferences.getBoolean("enabled", false))
private val _hasPermissions = MutableStateFlow(preferences.getBoolean("permissions", false))
@@ -46,21 +46,21 @@ class HealthConnectManager(private val context: Context) {
private const val TAG = "HealthConnectManager"
val REQUIRED_PERMISSIONS =
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
)
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
)
}
private val healthConnectClient by lazy {
try {
HealthConnectClient.getOrCreate(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to create Health Connect client", e)
AppLogger.e(TAG, e) { "Failed to create Health Connect client" }
_isCompatible.value = false
null
}
@@ -75,7 +75,7 @@ class HealthConnectManager(private val context: Context) {
val status = HealthConnectClient.getSdkStatus(context)
emit(status == HealthConnectClient.SDK_AVAILABLE)
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect availability", e)
AppLogger.e(TAG, e) { "Error checking Health Connect availability" }
_isCompatible.value = false
emit(false)
}
@@ -90,10 +90,10 @@ class HealthConnectManager(private val context: Context) {
try {
val alreadyHasPermissions = hasAllPermissions()
if (!alreadyHasPermissions) {
Log.d(TAG, "Health Connect enabled - permissions will be requested by UI")
AppLogger.d(TAG) { "Health Connect enabled - permissions will be requested by UI" }
}
} catch (e: Exception) {
Log.w(TAG, "Error checking permissions when enabling Health Connect", e)
AppLogger.w(TAG, e) { "Error checking permissions when enabling Health Connect" }
}
} else if (!enabled) {
setPermissionsGranted(false)
@@ -111,15 +111,15 @@ class HealthConnectManager(private val context: Context) {
return false
}
val grantedPermissions =
healthConnectClient!!.permissionController.getGrantedPermissions()
healthConnectClient!!.permissionController.getGrantedPermissions()
val hasAll =
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
REQUIRED_PERMISSIONS.all { permission ->
grantedPermissions.contains(permission)
}
setPermissionsGranted(hasAll)
hasAll
} catch (e: Exception) {
Log.e(TAG, "Error checking permissions", e)
AppLogger.e(TAG, e) { "Error checking permissions" }
setPermissionsGranted(false)
false
}
@@ -128,14 +128,14 @@ class HealthConnectManager(private val context: Context) {
suspend fun isReady(): Boolean {
return try {
if (!_isEnabled.value || !_isCompatible.value || healthConnectClient == null)
return false
return false
val isAvailable =
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
val hasPerms = if (isAvailable) hasAllPermissions() else false
isAvailable && hasPerms
} catch (e: Exception) {
Log.e(TAG, "Error checking Health Connect readiness", e)
AppLogger.e(TAG, e) { "Error checking Health Connect readiness" }
false
}
}
@@ -148,27 +148,27 @@ class HealthConnectManager(private val context: Context) {
return try {
REQUIRED_PERMISSIONS.map { it }.toSet()
} catch (e: Exception) {
Log.e(TAG, "Error getting required permissions", e)
AppLogger.e(TAG, e) { "Error getting required permissions" }
emptySet()
}
}
@SuppressLint("RestrictedApi")
suspend fun syncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return try {
if (!isReady() || !_autoSync.value) {
return Result.failure(
IllegalStateException("Health Connect not ready or auto-sync disabled")
IllegalStateException("Health Connect not ready or auto-sync disabled")
)
}
if (session.status != SessionStatus.COMPLETED) {
return Result.failure(
IllegalArgumentException("Only completed sessions can be synced")
IllegalArgumentException("Only completed sessions can be synced")
)
}
@@ -177,29 +177,29 @@ class HealthConnectManager(private val context: Context) {
if (startTime == null || endTime == null) {
return Result.failure(
IllegalArgumentException("Session must have valid start and end times")
IllegalArgumentException("Session must have valid start and end times")
)
}
Log.d(TAG, "Attempting to sync session '${session.id}' to Health Connect...")
AppLogger.d(TAG) { "Attempting to sync session '${session.id}' to Health Connect..." }
val records = mutableListOf<androidx.health.connect.client.records.Record>()
try {
val exerciseSession =
ExerciseSessionRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName"
)
ExerciseSessionRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
exerciseType =
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
title = "Rock Climbing at $gymName"
)
records.add(exerciseSession)
} catch (e: Exception) {
Log.w(TAG, "Failed to create exercise session record", e)
AppLogger.w(TAG, e) { "Failed to create exercise session record" }
}
try {
@@ -208,75 +208,74 @@ class HealthConnectManager(private val context: Context) {
if (estimatedCalories > 0) {
val caloriesRecord =
TotalCaloriesBurnedRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories)
)
TotalCaloriesBurnedRecord(
startTime = startTime,
startZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset =
ZoneOffset.systemDefault().rules.getOffset(endTime),
energy = Energy.calories(estimatedCalories)
)
records.add(caloriesRecord)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to create calories record", e)
AppLogger.w(TAG, e) { "Failed to create calories record" }
}
try {
val heartRateRecord = createHeartRateRecord(startTime, endTime, attemptCount)
heartRateRecord?.let { records.add(it) }
} catch (e: Exception) {
Log.w(TAG, "Failed to create heart rate record", e)
AppLogger.w(TAG, e) { "Failed to create heart rate record" }
}
if (records.isNotEmpty() && healthConnectClient != null) {
Log.d(TAG, "Writing ${records.size} records to Health Connect...")
AppLogger.d(TAG) { "Writing ${records.size} records to Health Connect..." }
healthConnectClient!!.insertRecords(records)
Log.i(
TAG,
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
)
AppLogger.i(TAG) {
"Successfully synced ${records.size} records for session '${session.id}' to Health Connect"
}
preferences
.edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply()
.edit()
.putString("last_sync_success", DateFormatUtils.nowISO8601())
.apply()
} else {
val reason =
when {
records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
Log.w(TAG, "Sync failed for session '${session.id}': $reason")
when {
records.isEmpty() -> "No records created"
healthConnectClient == null -> "Health Connect client unavailable"
else -> "Unknown reason"
}
AppLogger.w(TAG) { "Sync failed for session '${session.id}': $reason" }
return Result.failure(Exception("Sync failed: $reason"))
}
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Error syncing climbing session to Health Connect", e)
AppLogger.e(TAG, e) { "Error syncing climbing session to Health Connect" }
Result.failure(e)
}
}
suspend fun autoSyncCompletedSession(
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
session: ClimbSession,
gymName: String,
attemptCount: Int = 0
): Result<Unit> {
return if (_autoSync.value && isReady() && session.status == SessionStatus.COMPLETED) {
Log.d(TAG, "Auto-syncing completed session '${session.id}' to Health Connect...")
AppLogger.d(TAG) { "Auto-syncing completed session '${session.id}' to Health Connect..." }
syncCompletedSession(session, gymName, attemptCount)
} else {
val reason =
when {
session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
Log.d(TAG, "Auto-sync skipped for session '${session.id}': $reason")
when {
session.status != SessionStatus.COMPLETED -> "session not completed"
!_autoSync.value -> "auto-sync disabled"
!isReady() -> "Health Connect not ready"
else -> "unknown reason"
}
AppLogger.d(TAG) { "Auto-sync skipped for session '${session.id}': $reason" }
Result.success(Unit)
}
}
@@ -284,30 +283,30 @@ class HealthConnectManager(private val context: Context) {
private fun estimateCaloriesForClimbing(durationMinutes: Long, attemptCount: Int): Double {
val baseCaloriesPerMinute = 8.0
val intensityMultiplier =
when {
attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1
else -> 0.9
}
when {
attemptCount >= 20 -> 1.3
attemptCount >= 10 -> 1.1
else -> 0.9
}
return durationMinutes * baseCaloriesPerMinute * intensityMultiplier
}
@SuppressLint("RestrictedApi")
private fun createHeartRateRecord(
startTime: Instant,
endTime: Instant,
attemptCount: Int
startTime: Instant,
endTime: Instant,
attemptCount: Int
): HeartRateRecord? {
return try {
val samples = mutableListOf<HeartRateRecord.Sample>()
val intervalMinutes = 5L
val baseHeartRate =
when {
attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L
else -> 135L
}
when {
attemptCount >= 20 -> 155L
attemptCount >= 10 -> 145L
else -> 135L
}
var currentTime = startTime
while (currentTime.isBefore(endTime)) {
@@ -321,14 +320,14 @@ class HealthConnectManager(private val context: Context) {
if (samples.isEmpty()) return null
HeartRateRecord(
startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples
startTime = startTime,
startZoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime),
endTime = endTime,
endZoneOffset = ZoneOffset.systemDefault().rules.getOffset(endTime),
samples = samples
)
} catch (e: Exception) {
Log.e(TAG, "Error creating heart rate record", e)
AppLogger.e(TAG, e) { "Error creating heart rate record" }
null
}
}

View File

@@ -13,6 +13,7 @@ import com.atridad.ascently.data.format.DeletedItem
import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.state.DataStateManager
import com.atridad.ascently.utils.DateFormatUtils
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
@@ -26,7 +27,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null
@@ -43,11 +44,13 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateGym(gym: Gym) {
gymDao.updateGym(gym)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
@@ -63,10 +66,12 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
@@ -77,7 +82,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
suspend fun insertSession(session: ClimbSession) {
@@ -88,6 +94,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun updateSession(session: ClimbSession) {
sessionDao.updateSession(session)
dataStateManager.updateDataState()
@@ -96,12 +103,14 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
triggerAutoSync()
}
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun getLastUsedGym(): Gym? {
val recentSessions = sessionDao.getRecentSessions(1).first()
return if (recentSessions.isNotEmpty()) {
@@ -114,17 +123,21 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
// Attempt operations
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsByProblem(problemId)
attemptDao.getAttemptsByProblem(problemId)
suspend fun insertAttempt(attempt: Attempt) {
attemptDao.insertAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun updateAttempt(attempt: Attempt) {
attemptDao.updateAttempt(attempt)
dataStateManager.updateDataState()
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
@@ -141,38 +154,38 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
val backupData =
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
referencedImagePaths
.filter { imagePath ->
try {
val imageFile =
com.atridad.ascently.utils.ImageUtils.getImageFile(
context,
imagePath
)
imageFile.exists() && imageFile.length() > 0
} catch (_: Exception) {
false
}
}
.toSet()
ZipExportImportUtils.createExportZipToUri(
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
context = context,
uri = uri,
exportData = backupData,
referencedImagePaths = validImagePaths
)
} catch (e: Exception) {
throw Exception("Export failed: ${e.message}")
@@ -192,11 +205,11 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
} catch (e: Exception) {
throw Exception("Invalid data format: ${e.message}")
}
validateImportData(importData)
@@ -214,17 +227,17 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
} catch (e: Exception) {
throw Exception(
"Failed to import problem '${backupProblem.name}': ${e.message}"
"Failed to import problem '${backupProblem.name}': ${e.message}"
)
}
}
@@ -262,7 +275,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList()
val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion)
@@ -292,23 +305,23 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
)
}
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
)
}
@@ -316,10 +329,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
val sessionIds = sessions.map { it.id }.toSet()
val invalidAttempts =
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
if (invalidAttempts.isNotEmpty()) {
throw Exception(
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
)
}
}
@@ -334,9 +347,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
}
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
importData.attempts.size > 100000
) {
throw Exception("Import data is too large: possible corruption or malicious file")
}
@@ -386,10 +399,10 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context)
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0
imagesDir.deleteRecursively()
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
AppLogger.i("ClimbRepository") { "Cleared $deletedCount image files" }
}
} catch (e: Exception) {
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
AppLogger.w("ClimbRepository", e) { "Failed to clear some images: ${e.message}" }
}
}
}

View File

@@ -2,8 +2,8 @@ package com.atridad.ascently.data.state
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.DateFormatUtils
/**
@@ -20,13 +20,13 @@ class DataStateManager(context: Context) {
}
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
if (!isInitialized()) {
updateDataState()
markAsInitialized()
Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}")
AppLogger.d(TAG) { "DataStateManager initialized with timestamp: ${getLastModified()}" }
}
}
@@ -37,7 +37,7 @@ class DataStateManager(context: Context) {
fun updateDataState() {
val now = DateFormatUtils.nowISO8601()
prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now")
AppLogger.d(TAG) { "Data state updated to: $now" }
}
/**
@@ -46,7 +46,7 @@ class DataStateManager(context: Context) {
*/
fun getLastModified(): String {
return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601())
?: DateFormatUtils.nowISO8601()
?: DateFormatUtils.nowISO8601()
}
/** Checks if the data state has been initialized. */

View File

@@ -4,10 +4,10 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import 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
@@ -53,14 +53,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE)
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()
OkHttpClient.Builder()
.connectTimeout(45, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(90, TimeUnit.SECONDS)
.build()
private val json = Json {
prettyPrint = true
@@ -151,7 +151,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
private fun isNetworkAvailable(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
@@ -164,12 +164,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
@RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() {
if (isOfflineMode) {
Log.d(TAG, "Sync skipped: Offline mode is enabled.")
AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." }
return
}
if (!isNetworkAvailable()) {
_syncError.value = "No internet connection."
Log.d(TAG, "Sync skipped: No internet connection.")
AppLogger.d(TAG) { "Sync skipped: No internet connection." }
return
}
if (!_isConfigured.value) {
@@ -188,43 +188,46 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val serverBackup = downloadData()
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
localBackup.sessions.isNotEmpty() ||
localBackup.attempts.isNotEmpty()
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()
serverBackup.gyms.isNotEmpty() ||
serverBackup.problems.isNotEmpty() ||
serverBackup.sessions.isNotEmpty() ||
serverBackup.attempts.isNotEmpty()
// If both client and server have been synced before, use delta sync
val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
if (hasLocalData && hasServerData && lastSyncTimeStr != null) {
Log.d(TAG, "Using delta sync for incremental updates")
AppLogger.d(TAG) { "Using delta sync for incremental updates" }
performDeltaSync(lastSyncTimeStr)
} else {
when {
!hasLocalData && hasServerData -> {
Log.d(TAG, "No local data found, performing full restore from server")
AppLogger.d(TAG) { "No local data found, performing full restore from server" }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed")
AppLogger.d(TAG) { "Full restore completed" }
}
hasLocalData && !hasServerData -> {
Log.d(TAG, "No server data found, uploading local data to server")
AppLogger.d(TAG) { "No server data found, uploading local data to server" }
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed")
AppLogger.d(TAG) { "Initial upload completed" }
}
hasLocalData && hasServerData -> {
Log.d(TAG, "Both local and server data exist, merging (server wins)")
AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" }
mergeDataSafely(serverBackup)
Log.d(TAG, "Merge completed")
AppLogger.d(TAG) { "Merge completed" }
}
else -> {
Log.d(TAG, "No data to sync")
AppLogger.d(TAG) { "No data to sync" }
}
}
}
@@ -242,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
private suspend fun performDeltaSync(lastSyncTimeStr: String) {
Log.d(TAG, "Starting delta sync with lastSyncTime=$lastSyncTimeStr")
AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" }
// Parse last sync time to filter modified items
val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0)
@@ -250,102 +253,100 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// 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) }
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
allProblems
.filter { problem ->
parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true
}
Log.d(
TAG,
"Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}"
)
// Create delta request
val deltaRequest =
DeltaSyncRequest(
lastSyncTime = lastSyncTimeStr,
gyms = modifiedGyms,
problems = modifiedProblems,
sessions = modifiedSessions,
attempts = modifiedAttempts,
deletedItems = modifiedDeletions
)
val requestBody =
json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest)
.toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync/delta")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
val deltaResponse =
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (!body.isNullOrEmpty()) {
json.decodeFromString(DeltaSyncResponse.serializer(), body)
} else {
throw SyncException.InvalidResponse("Empty response body")
}
} else {
handleHttpError(response.code)
}
.map { problem ->
val backupProblem = BackupProblem.fromProblem(problem)
val normalizedImagePaths =
problem.imagePaths.mapIndexed { index, _ ->
ImageNamingUtils.generateImageFilename(problem.id, index)
}
} catch (e: IOException) {
throw SyncException.NetworkError(e.message ?: "Network error")
if (normalizedImagePaths.isNotEmpty()) {
backupProblem.copy(imagePaths = normalizedImagePaths)
} else {
backupProblem
}
}
Log.d(
TAG,
"Delta sync received: gyms=${deltaResponse.gyms.size}, problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}"
)
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)
@@ -368,9 +369,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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 {
@@ -379,14 +393,17 @@ class SyncService(private val context: Context, private val repository: ClimbRep
imagePathMapping[imagePath] = localImagePath
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
// Merge gyms - check if exists and compare timestamps
// 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()
@@ -401,10 +418,13 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// 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
}
backupProblem.imagePaths?.map { oldPath ->
imagePathMapping[oldPath] ?: oldPath
}
val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths)
val problem = problemToMerge.toProblem()
@@ -421,6 +441,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// 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) {
@@ -435,6 +458,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// 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) {
@@ -446,15 +472,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Apply deletions
applyDeletions(response.deletedItems)
// Apply deletions again for safety
applyDeletions(uniqueDeletions)
// Update deletion records
val allDeletions = repository.getDeletedItems() + response.deletedItems
repository.clearDeletedItems()
allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach {
repository.trackDeletion(it.id, it.type)
}
uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) }
} finally {
// Re-enable auto-sync
repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
@@ -462,7 +485,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
private suspend fun applyDeletions(
deletions: List<com.atridad.ascently.data.format.DeletedItem>
deletions: List<com.atridad.ascently.data.format.DeletedItem>
) {
val existingGyms = repository.getAllGyms().first()
val existingProblems = repository.getAllProblems().first()
@@ -474,12 +497,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
"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) }
}
@@ -490,7 +516,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun syncModifiedImages(modifiedProblems: List<BackupProblem>) {
if (modifiedProblems.isEmpty()) return
Log.d(TAG, "Syncing images for ${modifiedProblems.size} modified problems")
AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" }
for (backupProblem in modifiedProblems) {
backupProblem.imagePaths?.forEach { imagePath ->
@@ -502,11 +528,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun downloadData(): ClimbDataBackup {
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.get()
.build()
return withContext(Dispatchers.IO) {
try {
@@ -517,11 +543,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
json.decodeFromString(body)
} else {
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
exportedAt = DateFormatUtils.nowISO8601(),
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
}
} else {
@@ -536,14 +562,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadData(backup: ClimbDataBackup) {
val requestBody =
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
json.encodeToString(backup).toRequestBody("application/json".toMediaType())
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.put(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
@@ -561,7 +587,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
Log.d(TAG, "Starting image download from server for $totalImages images")
AppLogger.d(TAG) { "Starting image download from server for $totalImages images" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
@@ -573,9 +599,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
imagePathMapping[imagePath] = localImagePath
}
} catch (_: SyncException.ImageNotFound) {
Log.w(TAG, "Image not found on server: $imagePath")
AppLogger.w(TAG) { "Image not found on server: $imagePath" }
} catch (e: Exception) {
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" }
}
}
}
@@ -585,10 +611,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun downloadImage(serverFilename: String): String? {
val request =
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
Request.Builder()
.url("$serverUrl/images/download?filename=$serverFilename")
.header("Authorization", "Bearer $authToken")
.build()
return withContext(Dispatchers.IO) {
try {
@@ -603,14 +629,14 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error downloading image $serverFilename", e)
AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" }
null
}
}
}
private suspend fun syncImagesForBackup(backup: ClimbDataBackup) {
Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems")
AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" }
withContext(Dispatchers.IO) {
backup.problems.forEach { problem ->
problem.imagePaths?.forEach { localPath ->
@@ -624,33 +650,32 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private suspend fun uploadImage(localPath: String, filename: String) {
val file = ImageUtils.getImageFile(context, localPath)
if (!file.exists()) {
Log.w(TAG, "Local image file not found, cannot upload: $localPath")
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()
Request.Builder()
.url("$serverUrl/images/upload?filename=$filename")
.header("Authorization", "Bearer $authToken")
.post(requestBody)
.build()
withContext(Dispatchers.IO) {
try {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Log.d(TAG, "Successfully uploaded image: $filename")
AppLogger.d(TAG) { "Successfully uploaded image: $filename" }
} else {
Log.w(
TAG,
"Failed to upload image $filename. Server responded with ${response.code}"
)
AppLogger.w(TAG) {
"Failed to upload image $filename. Server responded with ${response.code}"
}
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error uploading image $filename", e)
AppLogger.e(TAG, e) { "Network error uploading image $filename" }
}
}
}
@@ -658,49 +683,49 @@ class SyncService(private val context: Context, private val repository: ClimbRep
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()
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>
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()
}
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() }
@@ -715,7 +740,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
Log.d(TAG, "Server data will overwrite local data. Performing full restore.")
AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." }
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
}
@@ -738,11 +763,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_syncError.value = null
val request =
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
Request.Builder()
.url("$serverUrl/sync")
.header("Authorization", "Bearer $authToken")
.head()
.build()
try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute().use { response ->
@@ -771,18 +796,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
syncJob?.cancel()
syncJob =
serviceScope.launch {
delay(syncDebounceDelay)
try {
syncWithServer()
} catch (e: Exception) {
Log.e(TAG, "Auto-sync failed", e)
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
serviceScope.launch {
delay(syncDebounceDelay)
try {
syncWithServer()
} catch (e: Exception) {
AppLogger.e(TAG, e) { "Auto-sync failed" }
}
if (pendingChanges) {
pendingChanges = false
triggerAutoSync()
}
}
}
fun clearConfiguration() {
@@ -800,7 +825,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
sealed class SyncException(message: String) : IOException(message), Serializable {
object NotConfigured :
SyncException("Sync is not configured. Please set server URL and auth token.")
SyncException("Sync is not configured. Please set server URL and auth token.")
object NotConnected : SyncException("Not connected to server. Please test connection first.")
@@ -810,6 +835,7 @@ sealed class SyncException(message: String) : IOException(message), Serializable
data class ServerError(val code: Int) : SyncException("Server error: HTTP $code")
data class InvalidResponse(val details: String) :
SyncException("Invalid server response: $details")
SyncException("Invalid server response: $details")
data class NetworkError(val details: String) : SyncException("Network error: $details")
}

View File

@@ -6,41 +6,47 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.atridad.ascently.MainActivity
import com.atridad.ascently.R
import com.atridad.ascently.data.database.AscentlyDatabase
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.firstOrNull
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class SessionTrackingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var notificationJob: Job? = null
private var monitoringJob: Job? = null
private lateinit var repository: ClimbRepository
private lateinit var notificationManager: NotificationManager
companion object {
private const val LOG_TAG = "SessionTrackingService"
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "session_tracking_channel"
const val ACTION_START_SESSION = "start_session"
const val ACTION_STOP_SESSION = "stop_session"
const val EXTRA_SESSION_ID = "session_id"
fun createStartIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_START_SESSION
putExtra(EXTRA_SESSION_ID, sessionId)
}
}
fun createStopIntent(context: Context, sessionId: String): Intent {
return Intent(context, SessionTrackingService::class.java).apply {
action = ACTION_STOP_SESSION
@@ -48,17 +54,17 @@ class SessionTrackingService : Service() {
}
}
}
override fun onCreate() {
super.onCreate()
val database = AscentlyDatabase.getDatabase(this)
repository = ClimbRepository(database, this)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_SESSION -> {
@@ -67,16 +73,24 @@ class SessionTrackingService : Service() {
startSessionTracking(sessionId)
}
}
ACTION_STOP_SESSION -> {
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
serviceScope.launch {
try {
val targetSession = when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null && targetSession.status == com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val completed = with(com.atridad.ascently.data.model.ClimbSession) { targetSession.complete() }
val targetSession =
when {
sessionId != null -> repository.getSessionById(sessionId)
else -> repository.getActiveSession()
}
if (targetSession != null &&
targetSession.status ==
com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
val completed =
with(com.atridad.ascently.data.model.ClimbSession) {
targetSession.complete()
}
repository.updateSession(completed)
}
} finally {
@@ -90,61 +104,71 @@ class SessionTrackingService : Service() {
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
notificationJob?.cancel()
monitoringJob?.cancel()
try {
createAndShowNotification(sessionId)
// Update widget when session tracking starts
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to initialize session tracking notification" }
}
notificationJob = serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
monitoringJob = serviceScope.launch {
try {
while (isActive) {
delay(10000L)
notificationJob =
serviceScope.launch {
try {
if (!isNotificationActive()) {
delay(1000L)
createAndShowNotification(sessionId)
}
while (isActive) {
delay(5000L)
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
stopSessionTracking()
break
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Notification updater loop crashed" }
}
}
monitoringJob =
serviceScope.launch {
try {
while (isActive) {
delay(10000L)
if (!isNotificationActive()) {
updateNotification(sessionId)
}
val session = repository.getSessionById(sessionId)
if (session == null ||
session.status !=
com.atridad.ascently.data.model.SessionStatus
.ACTIVE
) {
stopSessionTracking()
break
}
}
} catch (e: Exception) {
AppLogger.e(LOG_TAG, e) { "Session monitoring loop crashed" }
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun stopSessionTracking() {
notificationJob?.cancel()
monitoringJob?.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
// Update widget when session tracking stops
ClimbStatsWidgetProvider.updateAllWidgets(this)
}
private fun isNotificationActive(): Boolean {
return try {
val activeNotifications = notificationManager.activeNotifications
@@ -153,97 +177,134 @@ class SessionTrackingService : Service() {
false
}
}
private suspend fun updateNotification(sessionId: String) {
try {
createAndShowNotification(sessionId)
// Update widget when notification updates
ClimbStatsWidgetProvider.updateAllWidgets(this)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to update notification; retrying in 10s" }
try {
delay(10000L)
createAndShowNotification(sessionId)
} catch (retryException: Exception) {
retryException.printStackTrace()
AppLogger.e(LOG_TAG, retryException) { "Retrying notification update failed" }
stopSessionTracking()
}
}
}
private fun createAndShowNotification(sessionId: String) {
try {
val session = runBlocking {
repository.getSessionById(sessionId)
}
if (session == null || session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE) {
val session = runBlocking { repository.getSessionById(sessionId) }
if (session == null ||
session.status != com.atridad.ascently.data.model.SessionStatus.ACTIVE
) {
stopSessionTracking()
return
}
val gym = runBlocking {
repository.getGymById(session.gymId)
}
val gym = runBlocking { repository.getGymById(session.gymId) }
val attempts = runBlocking {
repository.getAttemptsBySession(sessionId).firstOrNull() ?: emptyList()
}
val duration = session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
val notificationBuilder =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
// Use Live Update
if (Build.VERSION.SDK_INT >= 36) {
val startTimeMillis =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val zoneId = ZoneId.systemDefault()
start.atZone(zoneId).toInstant().toEpochMilli()
} catch (_: Exception) {
System.currentTimeMillis()
}
}
} catch (_: Exception) {
"Active"
}
} ?: "Active"
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Climbing Session Active")
.setContentText("${gym?.name ?: "Gym"}$duration${attempts.size} attempts")
.setSmallIcon(R.drawable.ic_mountains)
.setOngoing(true)
.setAutoCancel(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(createOpenAppIntent())
.addAction(
R.drawable.ic_mountains,
"Open Session",
createOpenAppIntent()
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"End Session",
createStopPendingIntent(sessionId)
)
.build()
?: System.currentTimeMillis()
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}${attempts.size} attempts"
)
.setWhen(startTimeMillis)
.setUsesChronometer(true)
.setShowWhen(true)
val extras = Bundle()
extras.putBoolean("android.extra.REQUEST_PROMOTED_ONGOING", true)
notificationBuilder.setExtras(extras)
} else {
// Fallback for older versions
val duration =
session.startTime?.let { startTime ->
try {
val start = LocalDateTime.parse(startTime)
val now = LocalDateTime.now()
val totalSeconds = ChronoUnit.SECONDS.between(start, now)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${totalSeconds}s"
}
} catch (_: Exception) {
"Active"
}
}
?: "Active"
notificationBuilder
.setContentTitle("Climbing Session Active")
.setContentText(
"${gym?.name ?: "Gym"}$duration${attempts.size} attempts"
)
}
val notification = notificationBuilder.build()
startForeground(NOTIFICATION_ID, notification)
notificationManager.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e(LOG_TAG, e) { "Failed to build session tracking notification" }
throw e
}
}
private fun createOpenAppIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = "OPEN_SESSION"
}
return PendingIntent.getActivity(
this,
0,
@@ -251,7 +312,7 @@ class SessionTrackingService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createStopPendingIntent(sessionId: String): PendingIntent {
val intent = createStopIntent(this, sessionId)
return PendingIntent.getService(
@@ -261,24 +322,26 @@ class SessionTrackingService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
val channel =
NotificationChannel(
CHANNEL_ID,
"Session Tracking",
NotificationManager.IMPORTANCE_DEFAULT
)
.apply {
description = "Shows active climbing session information"
setShowBadge(false)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
}
override fun onDestroy() {
super.onDestroy()
notificationJob?.cancel()

View File

@@ -26,15 +26,16 @@ import com.atridad.ascently.ui.components.NotificationPermissionDialog
import com.atridad.ascently.ui.screens.*
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.ui.viewmodel.ClimbViewModelFactory
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.AppShortcutManager
import com.atridad.ascently.utils.NotificationPermissionUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AscentlyApp(
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
shortcutAction: String? = null,
lastUsedGymId: String? = null,
onShortcutActionProcessed: () -> Unit = {}
) {
val navController = rememberNavController()
val context = LocalContext.current
@@ -45,26 +46,26 @@ fun AscentlyApp(
val repository = remember { ClimbRepository(database, context) }
val syncService = remember { SyncService(context, repository) }
val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
viewModel(factory = ClimbViewModelFactory(repository, syncService, context))
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
showNotificationPermissionDialog = false
}
}
LaunchedEffect(Unit) {
if (!hasCheckedNotificationPermission) {
hasCheckedNotificationPermission = true
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
!NotificationPermissionUtils.isNotificationPermissionGranted(context)
) {
showNotificationPermissionDialog = true
}
@@ -86,10 +87,10 @@ fun AscentlyApp(
LaunchedEffect(activeSession, gyms, lastUsedGym) {
AppShortcutManager.updateShortcuts(
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
context = context,
hasActiveSession = activeSession != null,
hasGyms = gyms.isNotEmpty(),
lastUsedGym = if (activeSession == null && gyms.size > 1) lastUsedGym else null
)
}
@@ -101,6 +102,7 @@ fun AscentlyApp(
launchSingleTop = true
}
}
AppShortcutManager.ACTION_END_SESSION -> {
navController.navigate(Screen.Sessions) {
popUpTo(0) { inclusive = true }
@@ -114,51 +116,36 @@ fun AscentlyApp(
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
"AscentlyApp",
"Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}"
)
AppLogger.d("AscentlyApp") { "Processing shortcut action: activeSession=$activeSession, gyms.size=${gyms.size}, lastUsedGymId=$lastUsedGymId, lastUsedGym=${lastUsedGym?.name}" }
if (activeSession == null) {
if (NotificationPermissionUtils.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
!NotificationPermissionUtils.isNotificationPermissionGranted(
context
)
) {
android.util.Log.d("AscentlyApp", "Showing notification permission dialog")
AppLogger.d("AscentlyApp") { "Showing notification permission dialog" }
showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
android.util.Log.d(
"AscentlyApp",
"Starting session with single gym: ${gyms.first().name}"
)
AppLogger.d("AscentlyApp") { "Starting session with single gym: ${gyms.first().name}" }
viewModel.startSession(context, gyms.first().id)
} else {
val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
if (targetGym != null) {
android.util.Log.d(
"AscentlyApp",
"Starting session with target gym: ${targetGym.name}"
)
AppLogger.d("AscentlyApp") { "Starting session with target gym: ${targetGym.name}" }
viewModel.startSession(context, targetGym.id)
} else {
android.util.Log.d(
"AscentlyApp",
"No target gym found, navigating to selection"
)
AppLogger.d("AscentlyApp") { "No target gym found, navigating to selection" }
navController.navigate(Screen.AddEditSession())
}
}
}
} else {
android.util.Log.d(
"AscentlyApp",
"Active session already exists: ${activeSession?.id}"
)
AppLogger.d("AscentlyApp") { "Active session already exists: ${activeSession?.id}" }
}
onShortcutActionProcessed()
@@ -168,79 +155,79 @@ fun AscentlyApp(
var fabConfig by remember { mutableStateOf<FabConfig?>(null) }
Scaffold(
bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = {
fabConfig?.let { config ->
FloatingActionButton(
onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = config.icon,
contentDescription = config.contentDescription
)
}
bottomBar = { AscentlyBottomNavigation(navController = navController) },
floatingActionButton = {
fabConfig?.let { config ->
FloatingActionButton(
onClick = config.onClick,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = config.icon,
contentDescription = config.contentDescription
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding)
navController = navController,
startDestination = Screen.Sessions,
modifier = Modifier.padding(innerPadding)
) {
composable<Screen.Sessions> {
LaunchedEffect(gyms, activeSession) {
fabConfig =
if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (NotificationPermissionUtils
.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils
.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
}
}
)
} else {
null
}
if (gyms.isNotEmpty() && activeSession == null) {
FabConfig(
icon = Icons.Default.PlayArrow,
contentDescription = "Start Session",
onClick = {
if (NotificationPermissionUtils
.shouldRequestNotificationPermission() &&
!NotificationPermissionUtils
.isNotificationPermissionGranted(
context
)
) {
showNotificationPermissionDialog = true
} else {
navController.navigate(Screen.AddEditSession())
}
}
)
} else {
null
}
}
SessionsScreen(
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
}
viewModel = viewModel,
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
}
)
}
composable<Screen.Problems> {
LaunchedEffect(gyms) {
fabConfig =
if (gyms.isNotEmpty()) {
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Problem",
onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
}
if (gyms.isNotEmpty()) {
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Problem",
onClick = {
navController.navigate(Screen.AddEditProblem())
}
)
} else {
null
}
}
ProblemsScreen(
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
viewModel = viewModel,
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -252,17 +239,17 @@ fun AscentlyApp(
composable<Screen.Gyms> {
LaunchedEffect(Unit) {
fabConfig =
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) }
)
FabConfig(
icon = Icons.Default.Add,
contentDescription = "Add Gym",
onClick = { navController.navigate(Screen.AddEditGym()) }
)
}
GymsScreen(
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
}
viewModel = viewModel,
onNavigateToGymDetail = { gymId ->
navController.navigate(Screen.GymDetail(gymId))
}
)
}
@@ -275,12 +262,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.SessionDetail>()
LaunchedEffect(Unit) { fabConfig = null }
SessionDetailScreen(
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
sessionId = args.sessionId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -288,12 +275,12 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.ProblemDetail>()
LaunchedEffect(Unit) { fabConfig = null }
ProblemDetailScreen(
problemId = args.problemId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId))
}
problemId = args.problemId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { problemId ->
navController.navigate(Screen.AddEditProblem(problemId = problemId))
}
)
}
@@ -301,18 +288,18 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.GymDetail>()
LaunchedEffect(Unit) { fabConfig = null }
GymDetailScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { gymId ->
navController.navigate(Screen.AddEditGym(gymId = gymId))
},
onNavigateToSessionDetail = { sessionId ->
navController.navigate(Screen.SessionDetail(sessionId))
},
onNavigateToProblemDetail = { problemId ->
navController.navigate(Screen.ProblemDetail(problemId))
}
)
}
@@ -320,9 +307,9 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditGym>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditGymScreen(
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
@@ -330,10 +317,10 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditProblem>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditProblemScreen(
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
problemId = args.problemId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
@@ -341,22 +328,22 @@ fun AscentlyApp(
val args = backStackEntry.toRoute<Screen.AddEditSession>()
LaunchedEffect(Unit) { fabConfig = null }
AddEditSessionScreen(
sessionId = args.sessionId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
sessionId = args.sessionId,
gymId = args.gymId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
onDismiss = { showNotificationPermissionDialog = false },
onRequestPermission = {
permissionLauncher.launch(
NotificationPermissionUtils.getNotificationPermissionString()
)
}
)
}
}
@@ -370,34 +357,34 @@ fun AscentlyBottomNavigation(navController: NavHostController) {
NavigationBar {
bottomNavigationItems.forEach { item ->
val isSelected =
when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
when (item.screen) {
is Screen.Sessions -> currentRoute?.contains("Session") == true
is Screen.Problems -> currentRoute?.contains("Problem") == true
is Screen.Gyms -> currentRoute?.contains("Gym") == true
is Screen.Analytics -> currentRoute?.contains("Analytics") == true
is Screen.Settings -> currentRoute?.contains("Settings") == true
else -> currentRoute?.contains(item.screen::class.simpleName ?: "") == true
}
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false
}
}
)
}
}
}
data class FabConfig(
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val contentDescription: String,
val onClick: () -> Unit
)

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog
import com.atridad.ascently.data.model.*
import com.atridad.ascently.ui.components.FullscreenImageViewer
import com.atridad.ascently.ui.components.ImageDisplaySection
import com.atridad.ascently.ui.components.ImagePicker
import com.atridad.ascently.ui.theme.CustomIcons
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
import com.atridad.ascently.utils.DateFormatUtils
@@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog(
// New problem creation state
var newProblemName by remember { mutableStateOf("") }
var newProblemGrade by remember { mutableStateOf("") }
var newProblemImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) }
var selectedDifficultySystem by remember {
mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE)
@@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog(
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = { showCreateProblem = false }) {
IconButton(
onClick = {
showCreateProblem = false
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
}
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
@@ -1905,6 +1914,21 @@ fun EnhancedAddAttemptDialog(
}
}
}
// Photos Section
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Photos (Optional)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
ImagePicker(
imageUris = newProblemImagePaths,
onImagesChanged = { newProblemImagePaths = it },
maxImages = 5
)
}
}
}
}
@@ -2069,7 +2093,9 @@ fun EnhancedAddAttemptDialog(
null
},
climbType = selectedClimbType,
difficulty = difficulty
difficulty = difficulty,
imagePaths =
newProblemImagePaths
)
onProblemCreated(newProblem)
@@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog(
notes = notes.ifBlank { null }
)
onAttemptAdded(attempt)
// Reset form
newProblemName = ""
newProblemGrade = ""
newProblemImagePaths = emptyList()
showCreateProblem = false
}
} else {
// Create attempt for selected problem

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import com.atridad.ascently.data.model.*
import com.atridad.ascently.data.repository.ClimbRepository
import com.atridad.ascently.data.sync.SyncService
import com.atridad.ascently.service.SessionTrackingService
import com.atridad.ascently.utils.AppLogger
import com.atridad.ascently.utils.ImageUtils
import com.atridad.ascently.widget.ClimbStatsWidgetProvider
import java.io.File
@@ -15,9 +16,9 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ClimbViewModel(
private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
private val repository: ClimbRepository,
val syncService: SyncService,
private val context: Context
) : ViewModel() {
// Health Connect manager
@@ -29,49 +30,49 @@ class ClimbViewModel(
// Data flows
val gyms =
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllGyms()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val problems =
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllProblems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val sessions =
repository
.getAllSessions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllSessions()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
val activeSession =
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
repository
.getActiveSessionFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null
)
val attempts =
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
repository
.getAllAttempts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList()
)
// Gym operations
fun addGym(gym: Gym, updateWidgets: Boolean = true) {
@@ -124,7 +125,7 @@ class ClimbViewModel(
problem.imagePaths.forEachIndexed { index, tempPath ->
if (tempPath.startsWith("temp_")) {
val finalPath =
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
ImageUtils.renameTemporaryImage(context, tempPath, problem.id, index)
finalImagePaths.add(finalPath ?: tempPath)
} else {
finalImagePaths.add(tempPath)
@@ -176,23 +177,23 @@ class ClimbViewModel(
val allProblems = repository.getAllProblems().first()
val updatedProblems =
allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList())
} else {
problem
}
allProblems.map { problem ->
if (problem.imagePaths.isNotEmpty()) {
problem.copy(imagePaths = emptyList())
} else {
problem
}
}
for (updatedProblem in updatedProblems) {
if (updatedProblem.imagePaths !=
allProblems.find { it.id == updatedProblem.id }?.imagePaths
allProblems.find { it.id == updatedProblem.id }?.imagePaths
) {
repository.insertProblemWithoutSync(updatedProblem)
}
}
println("Deleted $deletedCount image files and cleared image references")
AppLogger.i("ClimbViewModel") { "Deleted $deletedCount image files and cleared image references" }
}
}
@@ -225,7 +226,7 @@ class ClimbViewModel(
}
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
repository.getSessionsByGym(gymId)
repository.getSessionsByGym(gymId)
// Get last used gym for shortcut functionality
suspend fun getLastUsedGym(): Gym? = repository.getLastUsedGym()
@@ -233,41 +234,35 @@ class ClimbViewModel(
// Active session management
fun startSession(context: Context, gymId: String, notes: String? = null) {
viewModelScope.launch {
android.util.Log.d("ClimbViewModel", "startSession called with gymId: $gymId")
AppLogger.d("ClimbViewModel") { "startSession called with gymId: $gymId" }
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
.isNotificationPermissionGranted(context)
) {
android.util.Log.d("ClimbViewModel", "Notification permission not granted")
AppLogger.d("ClimbViewModel") { "Notification permission not granted" }
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
)
_uiState.value.copy(
error =
"Notification permission is required to track your climbing session. Please enable notifications in settings."
)
return@launch
}
val existingActive = repository.getActiveSession()
if (existingActive != null) {
android.util.Log.d(
"ClimbViewModel",
"Active session already exists: ${existingActive.id}"
)
AppLogger.d("ClimbViewModel") { "Active session already exists: ${existingActive.id}" }
_uiState.value =
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
_uiState.value.copy(
error = "There's already an active session. Please end it first."
)
return@launch
}
android.util.Log.d("ClimbViewModel", "Creating new session")
AppLogger.d("ClimbViewModel") { "Creating new session" }
val newSession = ClimbSession.create(gymId = gymId, notes = notes)
repository.insertSession(newSession)
android.util.Log.d(
"ClimbViewModel",
"Starting tracking service for session: ${newSession.id}"
)
AppLogger.d("ClimbViewModel") { "Starting tracking service for session: ${newSession.id}" }
// Start the tracking service
val serviceIntent = SessionTrackingService.createStartIntent(context, newSession.id)
context.startForegroundService(serviceIntent)
@@ -281,13 +276,13 @@ class ClimbViewModel(
fun endSession(context: Context, sessionId: String) {
viewModelScope.launch {
if (!com.atridad.ascently.utils.NotificationPermissionUtils
.isNotificationPermissionGranted(context)
.isNotificationPermissionGranted(context)
) {
_uiState.value =
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
_uiState.value.copy(
error =
"Notification permission is required to manage your climbing session. Please enable notifications in settings."
)
return@launch
}
@@ -313,7 +308,7 @@ class ClimbViewModel(
val activeSession = repository.getActiveSession()
if (activeSession != null && activeSession.status == SessionStatus.ACTIVE) {
val serviceIntent =
SessionTrackingService.createStartIntent(context, activeSession.id)
SessionTrackingService.createStartIntent(context, activeSession.id)
context.startForegroundService(serviceIntent)
}
}
@@ -348,32 +343,32 @@ class ClimbViewModel(
}
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
repository.getAttemptsBySession(sessionId)
repository.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
repository.getAttemptsByProblem(problemId)
repository.getAttemptsByProblem(problemId)
fun exportDataToZipUri(context: Context, uri: android.net.Uri) {
viewModelScope.launch {
try {
_uiState.value =
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
_uiState.value.copy(
isLoading = true,
message = "Creating ZIP file with images..."
)
repository.exportAllDataToZipUri(context, uri)
_uiState.value =
_uiState.value.copy(
isLoading = false,
message =
"Export complete! Your climbing data and images have been saved."
)
_uiState.value.copy(
isLoading = false,
message =
"Export complete! Your climbing data and images have been saved."
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
_uiState.value.copy(
isLoading = false,
error = "Export failed: ${e.message}"
)
}
}
}
@@ -385,23 +380,23 @@ class ClimbViewModel(
if (!file.name.lowercase().endsWith(".zip")) {
throw Exception(
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
"Only ZIP files are supported for import. Please use a ZIP file exported from Ascently."
)
}
repository.importDataFromZip(file)
_uiState.value =
_uiState.value.copy(
isLoading = false,
message = "Data imported successfully from ${file.name}"
)
_uiState.value.copy(
isLoading = false,
message = "Data imported successfully from ${file.name}"
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(
isLoading = false,
error = "Import failed: ${e.message}"
)
_uiState.value.copy(
isLoading = false,
error = "Import failed: ${e.message}"
)
}
}
}
@@ -448,13 +443,13 @@ class ClimbViewModel(
repository.resetAllData()
_uiState.value =
_uiState.value.copy(
isLoading = false,
message = "All data has been reset successfully"
)
_uiState.value.copy(
isLoading = false,
message = "All data has been reset successfully"
)
} catch (e: Exception) {
_uiState.value =
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
_uiState.value.copy(isLoading = false, error = "Reset failed: ${e.message}")
}
}
}
@@ -469,23 +464,20 @@ class ClimbViewModel(
val attemptCount = attempts.size
val result =
healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
healthConnectManager.autoSyncCompletedSession(
session,
gymName,
attemptCount
)
result.onFailure { error ->
if (healthConnectManager.isReadySync()) {
android.util.Log.w(
"ClimbViewModel",
"Health Connect sync failed: ${error.message}"
)
AppLogger.w("ClimbViewModel") { "Health Connect sync failed: ${error.message}" }
}
}
} catch (e: Exception) {
if (healthConnectManager.isReadySync()) {
android.util.Log.w("ClimbViewModel", "Health Connect sync error: ${e.message}")
AppLogger.w("ClimbViewModel") { "Health Connect sync error: ${e.message}" }
}
}
}
@@ -493,7 +485,7 @@ class ClimbViewModel(
}
data class ClimbUiState(
val isLoading: Boolean = false,
val message: String? = null,
val error: String? = null
val isLoading: Boolean = false,
val message: String? = null,
val error: String? = null
)

View File

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

View File

@@ -6,7 +6,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.util.Log
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
@@ -30,17 +29,17 @@ object ImageUtils {
/** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif(
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
): Boolean {
return try {
// Get EXIF data from original image
val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
// Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap)
@@ -73,7 +72,7 @@ object ImageUtils {
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Error saving image with EXIF data" }
false
}
}
@@ -86,11 +85,11 @@ object ImageUtils {
// Calculate the scaling factor
val scaleFactor =
if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
}
if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
}
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
@@ -119,7 +118,7 @@ object ImageUtils {
val file = getImageFile(context, relativePath)
file.delete()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to delete image: $relativePath" }
false
}
}
@@ -137,7 +136,7 @@ object ImageUtils {
sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to import image from source: ${sourceFile.name}" }
null
}
}
@@ -148,16 +147,16 @@ object ImageUtils {
val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile &&
(file.extension == "jpg" ||
file.extension == "jpeg" ||
file.extension == "png")
(file.extension == "jpg" ||
file.extension == "jpeg" ||
file.extension == "png")
) {
"$IMAGES_DIR/${file.name}"
} else null
}
?: emptyList()
?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to enumerate images directory" }
emptyList()
}
}
@@ -178,50 +177,47 @@ object ImageUtils {
tempFilename
} catch (e: Exception) {
Log.e("ImageUtils", "Error saving temporary image from URI", e)
AppLogger.e("ImageUtils", e) { "Error saving temporary image from URI" }
null
}
}
/** Renames a temporary image */
fun renameTemporaryImage(
context: Context,
tempFilename: String,
problemId: String,
imageIndex: Int
context: Context,
tempFilename: String,
problemId: String,
imageIndex: Int
): String? {
return try {
val tempFile = File(getImagesDirectory(context), tempFilename)
if (!tempFile.exists()) {
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename")
AppLogger.e("ImageUtils") { "Temporary file does not exist: $tempFilename" }
return null
}
val deterministicFilename =
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val finalFile = File(getImagesDirectory(context), deterministicFilename)
if (tempFile.renameTo(finalFile)) {
Log.d(
"ImageUtils",
"Renamed temporary image: $tempFilename -> $deterministicFilename"
)
AppLogger.d("ImageUtils") { "Renamed temporary image: $tempFilename -> $deterministicFilename" }
deterministicFilename
} else {
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
AppLogger.e("ImageUtils") { "Failed to rename temporary image: $tempFilename" }
null
}
} catch (e: Exception) {
Log.e("ImageUtils", "Error renaming temporary image", e)
AppLogger.e("ImageUtils", e) { "Error renaming temporary image" }
null
}
}
/** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename(
context: Context,
imageData: ByteArray,
filename: String
context: Context,
imageData: ByteArray,
filename: String
): String? {
return try {
val imageFile = File(getImagesDirectory(context), filename)
@@ -230,7 +226,7 @@ object ImageUtils {
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
// For large images, decode, compress, and try to preserve EXIF
val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Save compressed image
@@ -249,7 +245,7 @@ object ImageUtils {
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
AppLogger.w("ImageUtils") { "Failed to preserve EXIF data: ${e.message}" }
}
bitmap.recycle()
@@ -262,7 +258,7 @@ object ImageUtils {
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to save image from bytes: $filename" }
null
}
}
@@ -275,7 +271,7 @@ object ImageUtils {
orphanedImages.forEach { path -> deleteImage(context, path) }
} catch (e: Exception) {
e.printStackTrace()
AppLogger.e("ImageUtils", e) { "Failed to clean up orphaned images" }
}
}
}

View File

@@ -2,7 +2,6 @@ package com.atridad.ascently.utils
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
class MigrationManager(private val context: Context) {
@@ -14,7 +13,7 @@ class MigrationManager(private val context: Context) {
}
private val migrationPrefs: SharedPreferences =
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
context.getSharedPreferences(MIGRATION_PREFS, Context.MODE_PRIVATE)
/**
* Perform migration from OpenClimb to Ascently if needed This should be called early in app
@@ -22,11 +21,11 @@ class MigrationManager(private val context: Context) {
*/
fun migrateIfNeeded() {
if (migrationPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false)) {
Log.d(TAG, "Migration already completed, skipping")
AppLogger.d(TAG) { "Migration already completed, skipping" }
return
}
Log.i(TAG, "🔄 Starting migration from OpenClimb to Ascently...")
AppLogger.i(TAG) { "🔄 Starting migration from OpenClimb to Ascently..." }
var migrationCount = 0
// Migrate SharedPreferences
@@ -36,12 +35,9 @@ class MigrationManager(private val context: Context) {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, true) }
if (migrationCount > 0) {
Log.i(
TAG,
"🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently"
)
AppLogger.i(TAG) { "🎉 Migration completed! Migrated $migrationCount items from OpenClimb to Ascently" }
} else {
Log.i(TAG, " No OpenClimb data found to migrate")
AppLogger.i(TAG) { " No OpenClimb data found to migrate" }
}
}
@@ -51,12 +47,12 @@ class MigrationManager(private val context: Context) {
// Define preference file migrations
val preferenceFileMigrations =
listOf(
"openclimb_data_state" to "ascently_data_state",
"health_connect_prefs" to "health_connect_prefs", // Keep same name
"deleted_items" to "deleted_items", // Keep same name
"sync_preferences" to "sync_preferences" // Keep same name
)
listOf(
"openclimb_data_state" to "ascently_data_state",
"health_connect_prefs" to "health_connect_prefs", // Keep same name
"deleted_items" to "deleted_items", // Keep same name
"sync_preferences" to "sync_preferences" // Keep same name
)
for ((oldFileName, newFileName) in preferenceFileMigrations) {
if (oldFileName != newFileName) {
@@ -95,10 +91,7 @@ class MigrationManager(private val context: Context) {
// Clear old preferences
oldPrefs.edit { clear() }
Log.d(
TAG,
"✅ Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)"
)
AppLogger.d(TAG) { "Migrated preference file: $oldFileName$newFileName (${oldPrefs.all.size} keys)" }
return oldPrefs.all.size
}
@@ -111,12 +104,12 @@ class MigrationManager(private val context: Context) {
// Check for any openclimb-prefixed keys across all preference files
val preferencesToCheck =
listOf(
"ascently_data_state",
"health_connect_prefs",
"deleted_items",
"sync_preferences"
)
listOf(
"ascently_data_state",
"health_connect_prefs",
"deleted_items",
"sync_preferences"
)
for (prefFileName in preferencesToCheck) {
val prefs = context.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
@@ -150,7 +143,7 @@ class MigrationManager(private val context: Context) {
}
}
Log.d(TAG, "Migrated ${keysToMigrate.size} keys in $prefFileName")
AppLogger.d(TAG) { "Migrated ${keysToMigrate.size} keys in $prefFileName" }
count += keysToMigrate.size
}
}
@@ -166,6 +159,6 @@ class MigrationManager(private val context: Context) {
/** Reset migration state (for testing purposes) */
fun resetMigrationState() {
migrationPrefs.edit { putBoolean(MIGRATION_COMPLETED_KEY, false) }
Log.d(TAG, "Migration state reset")
AppLogger.d(TAG) { "Migration state reset" }
}
}

View File

@@ -22,19 +22,19 @@ object ZipExportImportUtils {
/** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip(
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
directory: File? = null
): File {
val exportDir =
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"Ascently"
)
directory
?: File(
context.getExternalFilesDir(
android.os.Environment.DIRECTORY_DOCUMENTS
),
"Ascently"
)
if (!exportDir.exists()) {
exportDir.mkdirs()
}
@@ -52,10 +52,11 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -78,24 +79,21 @@ object ZipExportImportUtils {
zipOut.closeEntry()
successfulImages++
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Image file not found or empty: $imagePath"
)
AppLogger.w("ZipExportImportUtils") {
"Image file not found or empty: $imagePath"
}
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
}
}
}
// Log export summary
android.util.Log.i(
"ZipExportImportUtils",
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
AppLogger.i("ZipExportImportUtils") {
"Export completed: ${successfulImages}/${referencedImagePaths.size} images included"
}
}
// Validate the created ZIP file
@@ -115,10 +113,10 @@ object ZipExportImportUtils {
/** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri(
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
@@ -131,10 +129,11 @@ object ZipExportImportUtils {
zipOut.closeEntry()
// Add JSON data file
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val json =
Json {
prettyPrint = true
ignoreUnknownKeys = true
}
val jsonString = json.encodeToString(exportData)
val jsonEntry = ZipEntry(DATA_JSON_FILENAME)
@@ -158,28 +157,26 @@ object ZipExportImportUtils {
successfulImages++
}
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to add image $imagePath: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to add image $imagePath: ${e.message}"
}
}
}
android.util.Log.i(
"ZipExportImportUtils",
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
)
AppLogger.i("ZipExportImportUtils") {
"Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included"
}
}
}
?: throw IOException("Could not open output stream")
?: throw IOException("Could not open output stream")
} catch (e: Exception) {
throw IOException("Failed to create export ZIP to URI: ${e.message}")
}
}
private fun createMetadata(
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
): String {
return buildString {
appendLine("Ascently Export Metadata")
@@ -197,8 +194,8 @@ object ZipExportImportUtils {
/** Data class to hold extraction results */
data class ImportResult(
val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path
val jsonContent: String,
val importedImagePaths: Map<String, String> // original filename -> new relative path
)
/** Extracts a ZIP file and returns the JSON content and imported image paths */
@@ -217,16 +214,17 @@ object ZipExportImportUtils {
// Read metadata for validation
val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("metadata")
android.util.Log.i(
"ZipExportImportUtils",
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
)
AppLogger.i("ZipExportImportUtils") {
"Found metadata: ${metadataContent.lines().take(3).joinToString()}"
}
}
entry.name == DATA_JSON_FILENAME -> {
// Read JSON data
jsonContent = zipIn.readBytes().toString(Charsets.UTF_8)
foundRequiredFiles.add("data")
}
entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> {
// Extract image file
val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/")
@@ -234,11 +232,11 @@ object ZipExportImportUtils {
try {
// Create temporary file to hold the extracted image
val tempFile =
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
File.createTempFile(
"import_image_",
"_$originalFilename",
context.cacheDir
)
FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) }
@@ -248,37 +246,33 @@ object ZipExportImportUtils {
val newPath = ImageUtils.importImageFile(context, tempFile)
if (newPath != null) {
importedImagePaths[originalFilename] = newPath
android.util.Log.d(
"ZipExportImportUtils",
"Successfully imported image: $originalFilename -> $newPath"
)
AppLogger.d("ZipExportImportUtils") {
"Successfully imported image: $originalFilename -> $newPath"
}
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Failed to import image: $originalFilename"
)
AppLogger.w("ZipExportImportUtils") {
"Failed to import image: $originalFilename"
}
}
} else {
android.util.Log.w(
"ZipExportImportUtils",
"Extracted image is empty: $originalFilename"
)
AppLogger.w("ZipExportImportUtils") {
"Extracted image is empty: $originalFilename"
}
}
// Clean up temp file
tempFile.delete()
} catch (e: Exception) {
android.util.Log.e(
"ZipExportImportUtils",
"Failed to process image $originalFilename: ${e.message}"
)
AppLogger.e("ZipExportImportUtils", e) {
"Failed to process image $originalFilename: ${e.message}"
}
}
}
else -> {
android.util.Log.d(
"ZipExportImportUtils",
"Skipping ZIP entry: ${entry.name}"
)
AppLogger.d("ZipExportImportUtils") {
"Skipping ZIP entry: ${entry.name}"
}
}
}
@@ -296,10 +290,9 @@ object ZipExportImportUtils {
throw IOException("Invalid ZIP file: data.json is empty")
}
android.util.Log.i(
"ZipExportImportUtils",
"Import extraction completed: ${importedImagePaths.size} images processed"
)
AppLogger.i("ZipExportImportUtils") {
"Import extraction completed: ${importedImagePaths.size} images processed"
}
return ImportResult(jsonContent, importedImagePaths)
} catch (e: Exception) {
@@ -312,16 +305,16 @@ object ZipExportImportUtils {
* the new ones after import
*/
fun updateProblemImagePaths(
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
): List<BackupProblem> {
return problems.map { problem ->
val updatedImagePaths =
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path
val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename]
}
(problem.imagePaths ?: emptyList()).mapNotNull { oldPath ->
// Extract filename from the old path
val filename = oldPath.substringAfterLast("/")
imagePathMapping[filename]
}
problem.withUpdatedImagePaths(updatedImagePaths)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[versions]
agp = "8.12.3"
kotlin = "2.2.20"
kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -9,17 +9,17 @@ androidxTestCore = "1.7.0"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
composeBom = "2025.10.00"
room = "2.8.2"
navigation = "2.9.5"
viewmodel = "2.9.4"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0"
composeBom = "2025.11.01"
room = "2.8.4"
navigation = "2.9.6"
viewmodel = "2.10.0"
kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.3.6"
exifinterface = "1.4.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

View File

@@ -25,9 +25,13 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.0",
"@astrojs/starlight": "^0.36.1",
"astro": "^5.14.5",
"sharp": "^0.34.4"
"@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.36.2",
"astro": "^5.16.0",
"qrcode": "^1.5.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6"
}
}

1369
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 29;
CURRENT_PROJECT_VERSION = 32;
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.2.0;
MARKETING_VERSION = 2.3.0;
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 = 29;
CURRENT_PROJECT_VERSION = 32;
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.2.0;
MARKETING_VERSION = 2.3.0;
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 = 29;
CURRENT_PROJECT_VERSION = 32;
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.2.0;
MARKETING_VERSION = 2.3.0;
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 = 29;
CURRENT_PROJECT_VERSION = 32;
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.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -0,0 +1,33 @@
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.
struct AscentlyShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor {
.teal
}
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: StartLastGymSessionIntent(),
phrases: [
"Start my climb in \(.applicationName)",
"Begin my last gym session in \(.applicationName)",
],
shortTitle: "Start Climb",
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,95 @@
import Foundation
/// User-visible errors that can arise while handling session-related intents.
enum SessionIntentError: LocalizedError {
case noRecentGym
case noActiveSession
case failedToStartSession
case failedToEndSession
var errorDescription: String? {
switch self {
case .noRecentGym:
return "There's no recent gym to start a session with."
case .noActiveSession:
return "There isn't an active session to end right now."
case .failedToStartSession:
return "Ascently couldn't start a new session."
case .failedToEndSession:
return "Ascently couldn't finish the active session."
}
}
}
struct SessionIntentSummary: Sendable {
let sessionId: UUID
let gymName: String
let status: SessionStatus
}
/// Central controller that exposes the minimal climbing session operations used by App Intents and shortcuts.
@MainActor
final class SessionIntentController {
private let dataManager: ClimbingDataManager
init(dataManager: ClimbingDataManager = .shared) {
self.dataManager = dataManager
}
/// Starts a new session using the most recently visited gym.
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Give a moment for data to be ready if app just launched
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
guard let lastGym = dataManager.getLastUsedGym() else {
logFailure(.noRecentGym, context: "No recorded sessions available")
throw SessionIntentError.noRecentGym
}
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession
}
return SessionIntentSummary(
sessionId: startedSession.id,
gymName: lastGym.name,
status: startedSession.status
)
}
/// Ends the currently active climbing session, if one exists.
func endActiveSession() async throws -> SessionIntentSummary {
guard let activeSession = dataManager.activeSession else {
logFailure(.noActiveSession, context: "No active session stored in data manager")
throw SessionIntentError.noActiveSession
}
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
logFailure(
.failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession
}
guard let gym = dataManager.gym(withId: completedSession.gymId) else {
logFailure(
.failedToEndSession,
context: "Gym missing for completed session \(completedSession.id)")
throw SessionIntentError.failedToEndSession
}
return SessionIntentSummary(
sessionId: completedSession.id,
gymName: gym.name,
status: completedSession.status
)
}
private func logFailure(_ error: SessionIntentError, context: String) {
// Logging from intent context - errors are visible to user via dialog
print("SessionIntentError: \(error). Context: \(context)")
}
}

View File

@@ -0,0 +1,43 @@
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

@@ -1,7 +1,19 @@
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
return true
}
}
@main
struct AscentlyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@StateObject private var dataManager = ClimbingDataManager()
@StateObject private var dataManager = ClimbingDataManager.shared
@State private var selectedTab = 0
@Environment(\.scenePhase) private var scenePhase
@State private var notificationObservers: [NSObjectProtocol] = []
@@ -91,11 +91,12 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("App will enter foreground - preparing Live Activity check")
Task {
Task { @MainActor in
AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive()
dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
@@ -107,10 +108,11 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("App did become active - checking Live Activity status")
Task {
Task { @MainActor in
AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle")
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
}

View File

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

View File

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

View File

@@ -12,10 +12,27 @@ class SyncService: ObservableObject {
@Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard
private let logTag = "SyncService"
private var syncTask: Task<Void, Never>?
private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0
private func logDebug(_ message: @autoclosure () -> String) {
AppLogger.debug(message(), tag: logTag)
}
private func logInfo(_ message: @autoclosure () -> String) {
AppLogger.info(message(), tag: logTag)
}
private func logWarning(_ message: @autoclosure () -> String) {
AppLogger.warning(message(), tag: logTag)
}
private func logError(_ message: @autoclosure () -> String) {
AppLogger.error(message(), tag: logTag)
}
private enum Keys {
static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token"
@@ -201,7 +218,7 @@ class SyncService: ObservableObject {
return false
}
print(
logInfo(
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
)
@@ -244,7 +261,7 @@ class SyncService: ObservableObject {
let decoder = JSONDecoder()
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
print(
logInfo(
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
)
@@ -266,9 +283,25 @@ class SyncService: ObservableObject {
{
let formatter = ISO8601DateFormatter()
// Merge and apply deletions first to prevent resurrection
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
logInfo(
"iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data"
)
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Build deleted item lookup map
let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id })
// Download images for new/modified problems from server
var imagePathMapping: [String: String] = [:]
for problem in response.problems {
if deletedItemSet.contains("problem:" + problem.id) {
continue
}
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
@@ -282,10 +315,10 @@ class SyncService: ObservableObject {
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
logInfo("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
logInfo("Failed to download image \(serverFilename): \(error)")
continue
}
}
@@ -293,6 +326,10 @@ class SyncService: ObservableObject {
// Merge gyms
for backupGym in response.gyms {
if deletedItemSet.contains("gym:" + backupGym.id) {
continue
}
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
{
let existing = dataManager.gyms[index]
@@ -306,6 +343,10 @@ class SyncService: ObservableObject {
// Merge problems
for backupProblem in response.problems {
if deletedItemSet.contains("problem:" + backupProblem.id) {
continue
}
var problemToMerge = backupProblem
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
@@ -341,6 +382,10 @@ class SyncService: ObservableObject {
// Merge sessions
for backupSession in response.sessions {
if deletedItemSet.contains("session:" + backupSession.id) {
continue
}
if let index = dataManager.sessions.firstIndex(where: {
$0.id.uuidString == backupSession.id
}) {
@@ -355,6 +400,10 @@ class SyncService: ObservableObject {
// Merge attempts
for backupAttempt in response.attempts {
if deletedItemSet.contains("attempt:" + backupAttempt.id) {
continue
}
if let index = dataManager.attempts.firstIndex(where: {
$0.id.uuidString == backupAttempt.id
}) {
@@ -367,9 +416,7 @@ class SyncService: ObservableObject {
}
}
// Apply deletions
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
// Apply deletions again for safety
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Save all changes
@@ -406,7 +453,7 @@ class SyncService: ObservableObject {
) async throws {
guard !modifiedProblems.isEmpty else { return }
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
logInfo("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
for backupProblem in modifiedProblems {
guard
@@ -435,9 +482,9 @@ class SyncService: ObservableObject {
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Uploaded modified problem image: \(consistentFilename)")
logInfo("Uploaded modified problem image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
logInfo("Failed to upload image \(consistentFilename): \(error)")
}
}
}
@@ -519,7 +566,7 @@ class SyncService: ObservableObject {
func syncWithServer(dataManager: ClimbingDataManager) async throws {
if isOfflineMode {
print("Sync skipped: Offline mode is enabled.")
logInfo("Sync skipped: Offline mode is enabled.")
return
}
@@ -556,7 +603,7 @@ class SyncService: ObservableObject {
// If both client and server have been synced before, use delta sync
if hasLocalData && hasServerData && lastSyncTime != nil {
print("iOS SYNC: Using delta sync for incremental updates")
logInfo("iOS SYNC: Using delta sync for incremental updates")
try await performDeltaSync(dataManager: dataManager)
// Update last sync time
@@ -567,32 +614,32 @@ class SyncService: ObservableObject {
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...")
logInfo("iOS SYNC: Case 1 - No local data, performing full restore from server")
logInfo("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
print("Importing data after images...")
logInfo("Importing data after images...")
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Full restore completed")
logInfo("Full restore completed")
} else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server
print("iOS SYNC: Case 2 - No server data, uploading local data to server")
logInfo("iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
print("Uploading local images to server...")
logInfo("Uploading local images to server...")
try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed")
logInfo("Initial upload completed")
} else if hasLocalData && hasServerData {
// Case 3: Both have data - use safe merge strategy
print("iOS SYNC: Case 3 - Merging local and server data safely")
logInfo("iOS SYNC: Case 3 - Merging local and server data safely")
try await mergeDataSafely(
localBackup: localBackup,
serverBackup: serverBackup,
dataManager: dataManager)
print("Safe merge completed")
logInfo("Safe merge completed")
} else {
print("No data to sync")
logInfo("No data to sync")
}
// Update last sync time
@@ -610,7 +657,7 @@ class SyncService: ObservableObject {
if let date = formatter.date(from: timestamp) {
return Int64(date.timeIntervalSince1970 * 1000)
}
print("Failed to parse timestamp: \(timestamp), using 0")
logInfo("Failed to parse timestamp: \(timestamp), using 0")
return 0
}
@@ -636,12 +683,12 @@ class SyncService: ObservableObject {
imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
logInfo("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
logInfo("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
logInfo("Failed to download image \(serverFilename): \(error)")
continue
}
}
@@ -674,18 +721,18 @@ class SyncService: ObservableObject {
).path
do {
try FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
print("Renamed local image: \(filename) -> \(consistentFilename)")
logInfo("Renamed local image: \(filename) -> \(consistentFilename)")
// Update problem's image path in memory for consistency
} catch {
print("Failed to rename local image, using original: \(error)")
logInfo("Failed to rename local image, using original: \(error)")
}
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Successfully uploaded image: \(consistentFilename)")
logInfo("Successfully uploaded image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
logInfo("Failed to upload image \(consistentFilename): \(error)")
// Continue with other images even if one fails
}
}
@@ -703,7 +750,7 @@ class SyncService: ObservableObject {
!activeSessionIds.contains($0.sessionId)
}
print(
logInfo(
"iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync"
)
@@ -778,26 +825,26 @@ class SyncService: ObservableObject {
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
print("Merging gyms...")
logInfo("Merging gyms...")
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: uniqueDeletions)
print("Merging problems...")
logInfo("Merging problems...")
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions)
print("Merging sessions...")
logInfo("Merging sessions...")
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: uniqueDeletions)
print("Merging attempts...")
logInfo("Merging attempts...")
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
@@ -857,7 +904,7 @@ class SyncService: ObservableObject {
&& !allDeletedAttemptIds.contains($0.id.uuidString)
}
print(
logInfo(
"iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import"
)
@@ -947,7 +994,7 @@ class SyncService: ObservableObject {
// Restore active sessions and their attempts after import
for session in activeSessions {
print("iOS IMPORT: Restoring active session: \(session.id)")
logInfo("iOS IMPORT: Restoring active session: \(session.id)")
dataManager.sessions.append(session)
if session.id == dataManager.activeSession?.id {
dataManager.activeSession = session
@@ -967,12 +1014,12 @@ class SyncService: ObservableObject {
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(backup.deletedItems) {
UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
logInfo("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
}
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
logInfo("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch {
throw SyncError.importFailed(error)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -317,7 +317,6 @@ struct ProblemsList: View {
}
Button {
// Use a spring animation for more natural movement
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)

View File

@@ -84,6 +84,8 @@ struct DataManagementSection: View {
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false
private static let logTag = "DataManagementSection"
var body: some View {
Section("Data Management") {
// Export Data
@@ -217,13 +219,14 @@ struct DataManagementSection: View {
try fileManager.removeItem(at: imageFile)
deletedCount += 1
} catch {
print("Failed to delete image: \(imageFile.lastPathComponent)")
AppLogger.error(
"Failed to delete image: \(imageFile.lastPathComponent)", tag: Self.logTag)
}
}
print("Deleted \(deletedCount) image files")
AppLogger.info("Deleted \(deletedCount) image files", tag: Self.logTag)
} catch {
print("Failed to access images directory: \(error)")
AppLogger.error("Failed to access images directory: \(error)", tag: Self.logTag)
}
// Delete all images from backup directory
@@ -235,7 +238,7 @@ struct DataManagementSection: View {
try? fileManager.removeItem(at: backupFile)
}
} catch {
print("Failed to access backup directory: \(error)")
AppLogger.error("Failed to access backup directory: \(error)", tag: Self.logTag)
}
// Clear image paths from all problems
@@ -260,20 +263,6 @@ struct AppInfoSection: View {
var body: some View {
Section("App Information") {
HStack {
Image("AppLogo")
.resizable()
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Ascently")
.font(.headline)
Text("Track your climbing progress")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
@@ -292,11 +281,13 @@ struct ExportDataView: View {
@State private var tempFileURL: URL?
@State private var isCreatingFile = true
private static let logTag = "ExportDataView"
var body: some View {
NavigationStack {
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
// Loading state
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
@@ -380,6 +371,7 @@ struct ExportDataView: View {
}
private func createTempFile() {
let logTag = Self.logTag // Capture before entering background queue
DispatchQueue.global(qos: .userInitiated).async {
do {
let formatter = ISO8601DateFormatter()
@@ -394,7 +386,9 @@ struct ExportDataView: View {
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
Task { @MainActor in
AppLogger.error("Could not access Documents directory", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false
}
@@ -410,7 +404,9 @@ struct ExportDataView: View {
self.isCreatingFile = false
}
} catch {
print("Failed to create export file: \(error)")
Task { @MainActor in
AppLogger.error("Failed to create export file: \(error)", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false
}
@@ -420,10 +416,12 @@ struct ExportDataView: View {
private func cleanupTempFile() {
if let fileURL = tempFileURL {
let logTag = Self.logTag // Capture before entering async closure
// Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)")
AppLogger.debug(
"Cleaned up export file: \(fileURL.lastPathComponent)", tag: logTag)
}
}
}
@@ -435,6 +433,8 @@ struct SyncSection: View {
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
private static let logTag = "SyncSection"
var body: some View {
Section("Sync") {
// Sync Status
@@ -579,11 +579,14 @@ struct SyncSection: View {
}
private func performSync() {
let logTag = Self.logTag // Capture before entering async context
Task {
do {
try await syncService.syncWithServer(dataManager: dataManager)
} catch {
print("Sync failed: \(error)")
await MainActor.run {
AppLogger.error("Sync failed: \(error)", tag: logTag)
}
}
}
}

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import (
"time"
)
const VERSION = "2.1.0"
const VERSION = "2.3.0"
func min(a, b int) int {
if a < b {
@@ -283,8 +283,16 @@ func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []Deleted
}
}
// Clean up tombstones older than 30 days to prevent unbounded growth
cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour)
result := make([]DeletedItem, 0, len(deletedMap))
for _, item := range deletedMap {
deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt)
if err == nil && deletedTime.Before(cutoffTime) {
log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s",
item.Type, item.ID, item.DeletedAt)
continue
}
result = append(result, item)
}
return result
@@ -533,15 +541,16 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
return
}
// Merge and apply deletions first to prevent resurrection
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems))
// Merge client changes into server data
serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms)
serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems)
serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions)
serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts)
serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems)
// Apply deletions to remove deleted items
s.applyDeletions(serverBackup, serverBackup.DeletedItems)
// Save merged data
if err := s.saveData(serverBackup); err != nil {
@@ -553,8 +562,15 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Parse client's last sync time
clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime)
if err != nil {
// If parsing fails, send everything
clientLastSync = time.Time{}
log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime)
}
// Build deleted item lookup map
deletedItemMap := make(map[string]bool)
for _, item := range serverBackup.DeletedItems {
key := item.Type + ":" + item.ID
deletedItemMap[key] = true
}
// Prepare response with items modified since client's last sync
@@ -569,6 +585,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter gyms modified after client's last sync
for _, gym := range serverBackup.Gyms {
if deletedItemMap["gym:"+gym.ID] {
continue
}
gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt)
if err == nil && gymTime.After(clientLastSync) {
response.Gyms = append(response.Gyms, gym)
@@ -577,6 +596,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter problems modified after client's last sync
for _, problem := range serverBackup.Problems {
if deletedItemMap["problem:"+problem.ID] {
continue
}
problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt)
if err == nil && problemTime.After(clientLastSync) {
response.Problems = append(response.Problems, problem)
@@ -585,6 +607,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter sessions modified after client's last sync
for _, session := range serverBackup.Sessions {
if deletedItemMap["session:"+session.ID] {
continue
}
sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt)
if err == nil && sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
@@ -593,6 +618,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) {
// Filter attempts created after client's last sync
for _, attempt := range serverBackup.Attempts {
if deletedItemMap["attempt:"+attempt.ID] {
continue
}
attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt)
if err == nil && attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)

501
sync/sync_test.go Normal file
View File

@@ -0,0 +1,501 @@
package main
import (
"path/filepath"
"testing"
"time"
)
// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect
func TestDeltaSyncDeletedItemResurrection(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
// Initial state: Server has one gym, one problem, one session with 8 attempts
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: gymID,
Name: "Test Gym",
SupportedClimbTypes: []string{"BOULDER"},
DifficultySystems: []string{"V"},
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Problems: []BackupProblem{
{
ID: problemID,
GymID: gymID,
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
},
Sessions: []BackupClimbSession{
{
ID: sessionID,
GymID: gymID,
Date: now.Format("2006-01-02"),
Status: "completed",
CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339),
},
},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client 1 syncs - gets all data
client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339)
deltaRequest1 := DeltaSyncRequest{
LastSyncTime: client1LastSync,
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Simulate delta sync for client 1
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
if len(serverBackup.Sessions) != 1 {
t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 8 {
t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts))
}
// Client 1 deletes the session locally
deleteTime := now.Format(time.RFC3339)
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: deleteTime},
}
// Also track attempt deletions
for _, attempt := range initialBackup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: deleteTime,
})
}
// Client 1 syncs deletion
deltaRequest2 := DeltaSyncRequest{
LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: deletions,
}
// Server processes deletion
serverBackup, _ = server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Verify deletions were applied on server
serverBackup, _ = server.loadData()
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) != 9 {
t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems))
}
// Client does local reset and pulls from server
deltaRequest3 := DeltaSyncRequest{
LastSyncTime: time.Time{}.Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
serverBackup, _ = server.loadData()
clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime)
// Build response
response := DeltaSyncResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Build deleted item map
deletedItemMap := make(map[string]bool)
for _, item := range serverBackup.DeletedItems {
key := item.Type + ":" + item.ID
deletedItemMap[key] = true
}
// Filter sessions (excluding deleted)
for _, session := range serverBackup.Sessions {
if deletedItemMap["session:"+session.ID] {
continue
}
sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt)
if sessionTime.After(clientLastSync) {
response.Sessions = append(response.Sessions, session)
}
}
// Filter attempts (excluding deleted)
for _, attempt := range serverBackup.Attempts {
if deletedItemMap["attempt:"+attempt.ID] {
continue
}
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
response.Attempts = append(response.Attempts, attempt)
}
}
// Send deletion records
for _, deletion := range serverBackup.DeletedItems {
deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt)
if deletionTime.After(clientLastSync) {
response.DeletedItems = append(response.DeletedItems, deletion)
}
}
if len(response.Sessions) != 0 {
t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions))
}
if len(response.Attempts) != 0 {
t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts))
}
if len(response.DeletedItems) < 9 {
t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems))
}
}
// TestDeltaSyncAttemptCount verifies all attempts are preserved
func TestDeltaSyncAttemptCount(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
gymID := "gym-1"
problemID := "problem-1"
sessionID := "session-1"
// Create session with 8 attempts
initialBackup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add 8 attempts at different times
baseTime := now.Add(-30 * time.Minute)
for i := 0; i < 8; i++ {
attempt := BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: problemID,
Result: "COMPLETED",
Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
}
initialBackup.Attempts = append(initialBackup.Attempts, attempt)
}
if err := server.saveData(initialBackup); err != nil {
t.Fatalf("Failed to save initial data: %v", err)
}
// Client syncs with lastSyncTime BEFORE all attempts were created
clientLastSync := baseTime.Add(-1 * time.Hour)
serverBackup, _ := server.loadData()
// Count attempts that should be returned
attemptCount := 0
for _, attempt := range serverBackup.Attempts {
attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt)
if attemptTime.After(clientLastSync) {
attemptCount++
}
}
if attemptCount != 8 {
t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount)
}
}
// TestTombstoneCleanup verifies old deletion records are cleaned up
func TestTombstoneCleanup(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
oldDeletion := DeletedItem{
ID: "old-item",
Type: "session",
DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old
}
recentDeletion := DeletedItem{
ID: "recent-item",
Type: "session",
DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old
}
existing := []DeletedItem{oldDeletion}
updates := []DeletedItem{recentDeletion}
merged := server.mergeDeletedItems(existing, updates)
// Old deletion should be cleaned up, only recent one remains
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged))
}
if len(merged) > 0 && merged[0].ID != "recent-item" {
t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID)
}
}
// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled
func TestMergeDeletedItemsDeduplication(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
deletion1 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
}
deletion2 := DeletedItem{
ID: "item-1",
Type: "session",
DeletedAt: now.Format(time.RFC3339), // Newer timestamp
}
existing := []DeletedItem{deletion1}
updates := []DeletedItem{deletion2}
merged := server.mergeDeletedItems(existing, updates)
if len(merged) != 1 {
t.Errorf("Expected 1 deletion record, got %d", len(merged))
}
if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt {
t.Errorf("Expected newer deletion timestamp to be kept")
}
}
// TestApplyDeletions verifies deletions are applied correctly
func TestApplyDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}},
DeletedItems: []DeletedItem{},
}
deletions := []DeletedItem{
{ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)},
{ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)},
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts))
}
if len(backup.Gyms) != 1 {
t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms))
}
if len(backup.Problems) != 1 {
t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems))
}
}
// TestCascadingDeletions verifies related items are handled properly
func TestCascadingDeletions(t *testing.T) {
server := &SyncServer{}
now := time.Now().UTC()
sessionID := "session-1"
backup := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
// Add multiple attempts for the session
for i := 0; i < 5; i++ {
backup.Attempts = append(backup.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: sessionID,
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
})
}
// Delete session - attempts should also be tracked as deleted
deletions := []DeletedItem{
{ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)},
}
for _, attempt := range backup.Attempts {
deletions = append(deletions, DeletedItem{
ID: attempt.ID,
Type: "attempt",
DeletedAt: now.Format(time.RFC3339),
})
}
server.applyDeletions(backup, deletions)
if len(backup.Sessions) != 0 {
t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions))
}
if len(backup.Attempts) != 0 {
t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts))
}
}
// TestFullSyncAfterReset verifies the reported user scenario
func TestFullSyncAfterReset(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
authToken: "test-token",
}
now := time.Now().UTC()
// Initial sync with data
initialData := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}},
Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}
for i := 0; i < 8; i++ {
initialData.Attempts = append(initialData.Attempts, BackupAttempt{
ID: "attempt-" + string(rune('1'+i)),
SessionID: "session-1",
ProblemID: "problem-1",
Result: "COMPLETED",
Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
server.saveData(initialData)
// Client deletes everything and syncs
deletions := []DeletedItem{
{ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
{ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)},
}
for i := 0; i < 8; i++ {
deletions = append(deletions, DeletedItem{
ID: "attempt-" + string(rune('1'+i)),
Type: "attempt",
DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339),
})
}
serverBackup, _ := server.loadData()
serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions)
server.applyDeletions(serverBackup, serverBackup.DeletedItems)
server.saveData(serverBackup)
// Client does local reset and pulls from server
serverBackup, _ = server.loadData()
if len(serverBackup.Gyms) != 0 {
t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms))
}
if len(serverBackup.Problems) != 0 {
t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems))
}
if len(serverBackup.Sessions) != 0 {
t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions))
}
if len(serverBackup.Attempts) != 0 {
t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts))
}
if len(serverBackup.DeletedItems) == 0 {
t.Errorf("Expected deletion records, got 0")
}
}