diff --git a/README.md b/README.md index 861e258..470600e 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,6 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems, and overall progress. This app is offline-only and requires no special permissions to run. Its built using Jetpack Compose with Material You support on Android and SwiftUI on iOS. -## Versions - -- Android: 1.7.0 -- iOS: 1.2.0 -- Sync: 1.0.0 - -## Stability -- Clients: 8/10 -- Server: 10/10 -- Schema: 9/10 (No more breaking changes) - -## Self-Hosted Sync Server - -You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example. - ## Download For Android do one of the following: @@ -28,6 +13,30 @@ For iOS: Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)! +## Self-Hosted Sync Server + +You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up using Docker. + +### Quick Start with Docker Compose + +1. Create a `.env` file with your configuration: +``` +IMAGE=git.atri.dad/atridad/openclimb-sync:latest +APP_PORT=8080 +AUTH_TOKEN=your-secure-auth-token-here +DATA_FILE=/data/openclimb.json +IMAGES_DIR=/data/images +ROOT_DIR=./openclimb-data +``` + +2. Use the provided `docker-compose.yml` in the `sync/` directory: +```bash +cd sync/ +docker-compose up -d +``` + +The server will be available at `http://localhost:8080`. Configure your clients with your server URL and auth token to start syncing. + ## Requirements - Android 12+ or iOS 17+ diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt index f8fae25..d97f448 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format import com.atridad.openclimb.data.model.* import kotlinx.serialization.Serializable -/** Root structure for OpenClimb backup data */ +// Root structure for OpenClimb backup data @Serializable data class ClimbDataBackup( val exportedAt: String, @@ -15,7 +15,7 @@ data class ClimbDataBackup( val attempts: List ) -/** Platform-neutral gym representation for backup/restore */ +// Platform-neutral gym representation for backup/restore @Serializable data class BackupGym( val id: String, @@ -26,8 +26,8 @@ data class BackupGym( @kotlinx.serialization.SerialName("customDifficultyGrades") val customDifficultyGrades: List = emptyList(), val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupGym from native Android Gym model */ @@ -62,7 +62,7 @@ data class BackupGym( } } -/** Platform-neutral problem representation for backup/restore */ +// Platform-neutral problem representation for backup/restore @Serializable data class BackupProblem( val id: String, @@ -75,10 +75,10 @@ data class BackupProblem( val location: String? = null, val imagePaths: List? = null, val isActive: Boolean = true, - val dateSet: String? = null, // ISO 8601 format + val dateSet: String? = null, val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupProblem from native Android Problem model */ @@ -94,11 +94,7 @@ data class BackupProblem( location = problem.location, imagePaths = if (problem.imagePaths.isEmpty()) null - else - problem.imagePaths.map { path -> - // Store just the filename to match iOS format - path.substringAfterLast('/') - }, + else problem.imagePaths.map { path -> path.substringAfterLast('/') }, isActive = problem.isActive, dateSet = problem.dateSet, notes = problem.notes, @@ -134,19 +130,19 @@ data class BackupProblem( } } -/** Platform-neutral climb session representation for backup/restore */ +// Platform-neutral climb session representation for backup/restore @Serializable data class BackupClimbSession( val id: String, val gymId: String, - val date: String, // ISO 8601 format - val startTime: String? = null, // ISO 8601 format - val endTime: String? = null, // ISO 8601 format - val duration: Long? = null, // Duration in seconds + val date: String, + val startTime: String? = null, + val endTime: String? = null, + val duration: Long? = null, val status: SessionStatus, val notes: String? = null, - val createdAt: String, // ISO 8601 format - val updatedAt: String // ISO 8601 format + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupClimbSession from native Android ClimbSession model */ @@ -183,7 +179,7 @@ data class BackupClimbSession( } } -/** Platform-neutral attempt representation for backup/restore */ +// Platform-neutral attempt representation for backup/restore @Serializable data class BackupAttempt( val id: String, @@ -192,10 +188,11 @@ data class BackupAttempt( val result: AttemptResult, val highestHold: String? = null, val notes: String? = null, - val duration: Long? = null, // Duration in seconds - val restTime: Long? = null, // Rest time in seconds - val timestamp: String, // ISO 8601 format - val createdAt: String // ISO 8601 format + val duration: Long? = null, + val restTime: Long? = null, + val timestamp: String, + val createdAt: String, + val updatedAt: String ) { companion object { /** Create BackupAttempt from native Android Attempt model */ diff --git a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt index 0ec8afc..6b0d619 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt @@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository migrationResults.putAll(problemMigrations) migratedCount += problemMigrations.size - // Update problem with new image paths + // Update image paths val newImagePaths = problem.imagePaths.map { oldPath -> problemMigrations[oldPath] ?: oldPath @@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository continue } - // Check if filename follows our convention + // Check if filename follows convention if (ImageNamingUtils.isValidImageFilename(filename)) { validImages.add(imagePath) } else { diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt index 794cf7d..0e35efa 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt @@ -39,11 +39,11 @@ data class Attempt( val sessionId: String, val problemId: String, val result: AttemptResult, - val highestHold: String? = null, // Description of the highest hold reached + val highestHold: String? = null, val notes: String? = null, - val duration: Long? = null, // Attempt duration in seconds - val restTime: Long? = null, // Rest time before this attempt in seconds - val timestamp: String, // When this attempt was made + val duration: Long? = null, + val restTime: Long? = null, + val timestamp: String, val createdAt: String ) { companion object { diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt index fd58bb9..33c28aa 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt @@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable @Serializable enum class DifficultySystem { // Bouldering - V_SCALE, // V-Scale (VB - V17) - FONT, // Fontainebleau (3 - 8C+) + V_SCALE, + FONT, // Rope - YDS, // Yosemite Decimal System (5.0 - 5.15d) - - // Custom difficulty systems + YDS, CUSTOM; /** Get the display name for the UI */ @@ -28,7 +26,7 @@ enum class DifficultySystem { when (this) { V_SCALE, FONT -> true YDS -> false - CUSTOM -> true // Custom is available for all + CUSTOM -> true } /** Check if this system is for rope climbing */ @@ -157,7 +155,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 } DifficultySystem.YDS -> { - // Simplified numeric mapping for YDS grades when { grade.startsWith("5.10") -> 10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) @@ -175,7 +172,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val } } DifficultySystem.FONT -> { - // Simplified Font grade mapping when { grade.startsWith("6A") -> 6 grade.startsWith("6B") -> 7 @@ -209,24 +205,20 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val } private fun compareVScaleGrades(grade1: String, grade2: String): Int { - // Handle VB (easiest) specially if (grade1 == "VB" && grade2 != "VB") return -1 if (grade2 == "VB" && grade1 != "VB") return 1 if (grade1 == "VB" && grade2 == "VB") return 0 - // Extract numeric values for V grades val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 return num1.compareTo(num2) } private fun compareFontGrades(grade1: String, grade2: String): Int { - // Simple string comparison for Font grades return grade1.compareTo(grade2) } private fun compareYDSGrades(grade1: String, grade2: String): Int { - // Simple string comparison for YDS grades return grade1.compareTo(grade2) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 01aced1..453df81 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private val attemptDao = database.attemptDao() private val dataStateManager = DataStateManager(context) - // Callback interface for auto-sync functionality private var autoSyncCallback: (() -> Unit)? = null private val json = Json { @@ -125,16 +124,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { try { - // Collect all data val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() val allAttempts = attemptDao.getAllAttempts().first() - // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - // Create backup data using platform-neutral format val backupData = ClimbDataBackup( exportedAt = DateFormatUtils.nowISO8601(), @@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } ) - // Collect all referenced image paths and validate they exist val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() val validImagePaths = referencedImagePaths @@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun importDataFromZip(file: File) { try { - // Validate the ZIP file if (!file.exists() || file.length() == 0L) { throw Exception("Invalid ZIP file: file is empty or doesn't exist") } - // Extract and validate the ZIP contents val importResult = ZipExportImportUtils.extractImportZip(context, file) - // Validate JSON content if (importResult.jsonContent.isBlank()) { throw Exception("Invalid ZIP file: no data.json found or empty content") } - // Parse and validate the data structure val importData = try { json.decodeFromString(importResult.jsonContent) @@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) throw Exception("Invalid data format: ${e.message}") } - // Validate data integrity validateImportData(importData) - // Clear existing data to avoid conflicts attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data - // state updates importData.gyms.forEach { backupGym -> try { gymDao.insertGym(backupGym.toGym()) @@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import problems with updated image paths val updatedBackupProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) - // Import problems (depends on gyms) - use DAO directly updatedBackupProblems.forEach { backupProblem -> try { problemDao.insertProblem(backupProblem.toProblem()) @@ -235,7 +220,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import sessions - use DAO directly importData.sessions.forEach { backupSession -> try { sessionDao.insertSession(backupSession.toClimbSession()) @@ -244,7 +228,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import attempts last (depends on problems and sessions) - use DAO directly importData.attempts.forEach { backupAttempt -> try { attemptDao.insertAttempt(backupAttempt.toAttempt()) @@ -253,7 +236,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Update data state once at the end to current time since we just imported new data dataStateManager.updateDataState() } catch (e: Exception) { throw Exception("Import failed: ${e.message}") @@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) sessions: List, attempts: List ) { - // Validate that all problems reference valid gyms val gymIds = gyms.map { it.id }.toSet() val invalidProblems = problems.filter { it.gymId !in gymIds } if (invalidProblems.isNotEmpty()) { @@ -291,7 +272,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ) } - // Validate that all sessions reference valid gyms val invalidSessions = sessions.filter { it.gymId !in gymIds } if (invalidSessions.isNotEmpty()) { throw Exception( @@ -299,7 +279,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ) } - // Validate that all attempts reference valid problems and sessions val problemIds = problems.map { it.id }.toSet() val sessionIds = sessions.map { it.id }.toSet() @@ -321,7 +300,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) throw Exception("Import data is invalid: no version information") } - // Check for reasonable data sizes to prevent malicious imports if (importData.gyms.size > 1000 || importData.problems.size > 10000 || importData.sessions.size > 10000 || @@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun resetAllData() { try { - // Temporarily disable auto-sync during reset val originalCallback = autoSyncCallback autoSyncCallback = null - // Clear all data from database attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Clear all images from storage clearAllImages() - // Restore auto-sync callback autoSyncCallback = originalCallback } catch (e: Exception) { throw Exception("Reset failed: ${e.message}") } } - // Import methods that bypass auto-sync to avoid triggering sync during data restoration suspend fun insertGymWithoutSync(gym: Gym) { gymDao.insertGym(gym) dataStateManager.updateDataState() @@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private fun clearAllImages() { try { - // Get the images directory val imagesDir = File(context.filesDir, "images") if (imagesDir.exists() && imagesDir.isDirectory) { val deletedCount = imagesDir.listFiles()?.size ?: 0 diff --git a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt index 7fedf7f..01ad1db 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt @@ -22,7 +22,6 @@ class DataStateManager(context: Context) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) init { - // Initialize with current timestamp if this is the first time if (!isInitialized()) { updateDataState() markAsInitialized() diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 45a033b..7e08d93 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupGym @@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import androidx.core.content.edit class SyncService(private val context: Context, private val repository: ClimbRepository) { @@ -61,7 +61,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep coerceInputValues = true } - // State flows + // State private val _isSyncing = MutableStateFlow(false) val isSyncing: StateFlow = _isSyncing.asStateFlow() @@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep } init { - // Initialize state from preferences _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) - // Register auto-sync callback with repository repository.setAutoSyncCallback { - kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - triggerAutoSync() - } + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() } } } @@ -153,7 +149,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep "Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}" ) - // Log problems with images backup.problems.forEach { problem -> val imageCount = problem.imagePaths?.size ?: 0 if (imageCount > 0) { @@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep throw SyncException.NotConfigured } - // Server expects filename as query parameter and raw image data in body - // Extract just the filename without directory path val justFilename = filename.substringAfterLast('/') val requestBody = imageData.toRequestBody("image/*".toMediaType()) @@ -252,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep val response = httpClient.newCall(request).execute() when (response.code) { - 200 -> Unit // Success + 200 -> Unit 401 -> throw SyncException.Unauthorized else -> { val errorBody = response.body?.string() ?: "No error details" @@ -325,33 +318,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep throw SyncException.NotConnected } - // Prevent concurrent sync operations syncMutex.withLock { _isSyncing.value = true _syncError.value = null try { - // Fix existing image paths first Log.d(TAG, "Fixing existing image paths before sync") val pathFixSuccess = fixImagePaths() if (!pathFixSuccess) { Log.w(TAG, "Image path fix failed, but continuing with sync") } - // Migrate images to consistent naming second Log.d(TAG, "Performing image migration before sync") val migrationSuccess = migrateImagesForSync() if (!migrationSuccess) { Log.w(TAG, "Image migration failed, but continuing with sync") } - // Get local backup data val localBackup = createBackupFromRepository() - // Download server data val serverBackup = downloadData() - // Check if we have any local data val hasLocalData = localBackup.gyms.isNotEmpty() || localBackup.problems.isNotEmpty() || @@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep when { !hasLocalData && hasServerData -> { - // Case 1: No local data - do full restore from server Log.d(TAG, "No local data found, performing full restore from server") val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) Log.d(TAG, "Full restore completed") } hasLocalData && !hasServerData -> { - // Case 2: No server data - upload local data to server Log.d(TAG, "No server data found, uploading local data to server") uploadData(localBackup) syncImagesForBackup(localBackup) Log.d(TAG, "Initial upload completed") } hasLocalData && hasServerData -> { - // Case 3: Both have data - compare timestamps (last writer wins) val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) @@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) if (localTimestamp > serverTimestamp) { - // Local is newer - replace server with local data Log.d(TAG, "Local data is newer, replacing server content") uploadData(localBackup) syncImagesForBackup(localBackup) Log.d(TAG, "Server replaced with local data") } else if (serverTimestamp > localTimestamp) { - // Server is newer - replace local with server data Log.d(TAG, "Server data is newer, replacing local content") val imagePathMapping = syncImagesFromServer(serverBackup) importBackupToRepository(serverBackup, imagePathMapping) Log.d(TAG, "Local data replaced with server data") } else { - // Timestamps are equal - no sync needed Log.d(TAG, "Data is in sync (timestamps equal), no action needed") } } @@ -411,7 +392,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Update last sync time val now = DateFormatUtils.nowISO8601() _lastSyncTime.value = now sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() @@ -447,13 +427,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep Log.d(TAG, "Attempting to download image: $imagePath") val imageData = downloadImage(imagePath) - // Extract filename and ensure it follows our naming convention val serverFilename = imagePath.substringAfterLast('/') val consistentFilename = if (ImageNamingUtils.isValidImageFilename(serverFilename)) { serverFilename } else { - // Generate consistent filename using problem ID and index ImageNamingUtils.generateImageFilename(problem.id, index) } @@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) if (localImagePath != null) { - // Map original server filename to the full local relative path imagePathMapping[serverFilename] = localImagePath downloadedImages++ Log.d( @@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep val imageData = imageFile.readBytes() val filename = imagePath.substringAfterLast('/') - // Ensure filename follows our naming convention val consistentFilename = if (ImageNamingUtils.isValidImageFilename(filename)) { filename } else { - // Generate consistent filename and rename the local file val newFilename = ImageNamingUtils.generateImageFilename( problem.id, @@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep TAG, "Renamed local image file: $filename -> $newFilename" ) - // Update the problem's image path in memory for next sync newFilename } else { Log.w( @@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep backup: ClimbDataBackup, imagePathMapping: Map = emptyMap() ) { - // Clear existing data to avoid conflicts repository.resetAllData() - // Import gyms first (problems depend on gyms) backup.gyms.forEach { backupGym -> try { val gym = backupGym.toGym() @@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep repository.insertGymWithoutSync(gym) } catch (e: Exception) { Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") - throw e // Stop import if gym fails since problems depend on it + throw e } } - // Import problems with updated image paths backup.problems.forEach { backupProblem -> try { val updatedProblem = if (imagePathMapping.isNotEmpty()) { val newImagePaths = backupProblem.imagePaths?.map { oldPath -> - // Extract filename and check mapping val filename = oldPath.substringAfterLast('/') - // Use mapped full path or fallback to consistent naming - // with full path + imagePathMapping[filename] ?: if (ImageNamingUtils.isValidImageFilename( filename @@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep ) { "problem_images/$filename" } else { - // Generate consistent filename as fallback with - // full path val index = backupProblem.imagePaths.indexOf( oldPath @@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Import sessions backup.sessions.forEach { backupSession -> try { repository.insertSessionWithoutSync(backupSession.toClimbSession()) @@ -656,7 +622,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Import attempts last backup.attempts.forEach { backupAttempt -> try { repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) @@ -665,7 +630,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Update local data state to match imported data timestamp dataStateManager.setLastModified(backup.exportedAt) Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") } @@ -697,7 +661,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep val fixedPaths = problem.imagePaths.map { path -> if (!path.startsWith("problem_images/") && !path.contains("/")) { - // Just a filename, add the directory prefix val fixedPath = "problem_images/$path" Log.d(TAG, "Fixed path: $path -> $fixedPath") fixedCount++ @@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep return } - // Check if sync is already running to prevent duplicate attempts if (_isSyncing.value) { Log.d(TAG, "Sync already in progress, skipping auto-sync") return diff --git a/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt b/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt index 1a5ef77..c459e07 100644 --- a/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/navigation/Screen.kt @@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable @Serializable sealed class Screen { - @Serializable - data object Sessions : Screen() - - @Serializable - data object Problems : Screen() - - @Serializable - data object Analytics : Screen() - - @Serializable - data object Gyms : Screen() - - @Serializable - data object Settings : Screen() - - // Detail screens - @Serializable - data class SessionDetail(val sessionId: String) : Screen() - - @Serializable - data class ProblemDetail(val problemId: String) : Screen() - - @Serializable - data class GymDetail(val gymId: String) : Screen() - - @Serializable - data class AddEditGym(val gymId: String? = null) : Screen() - + @Serializable data object Sessions : Screen() + + @Serializable data object Problems : Screen() + + @Serializable data object Analytics : Screen() + + @Serializable data object Gyms : Screen() + + @Serializable data object Settings : Screen() + + @Serializable data class SessionDetail(val sessionId: String) : Screen() + + @Serializable data class ProblemDetail(val problemId: String) : Screen() + + @Serializable data class GymDetail(val gymId: String) : Screen() + + @Serializable data class AddEditGym(val gymId: String? = null) : Screen() + @Serializable data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen() - + @Serializable data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen() - - } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt index a8bbca2..1e42333 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -47,11 +47,9 @@ fun OpenClimbApp( val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository, syncService)) - // Notification permission state var showNotificationPermissionDialog by remember { mutableStateOf(false) } var hasCheckedNotificationPermission by remember { mutableStateOf(false) } - // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() @@ -75,13 +73,11 @@ fun OpenClimbApp( LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) } - // Trigger auto-sync on app launch LaunchedEffect(Unit) { syncService.triggerAutoSync() } val activeSession by viewModel.activeSession.collectAsState() val gyms by viewModel.gyms.collectAsState() - // Update last used gym when gyms change LaunchedEffect(gyms) { if (gyms.isNotEmpty() && lastUsedGym == null) { lastUsedGym = viewModel.getLastUsedGym() @@ -116,7 +112,6 @@ fun OpenClimbApp( } } - // Process shortcut actions after data is loaded LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) { if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) { android.util.Log.d( @@ -140,7 +135,6 @@ fun OpenClimbApp( ) viewModel.startSession(context, gyms.first().id) } else { - // Try to get the last used gym from the intent or fallback to state val targetGym = lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } } ?: lastUsedGym @@ -167,7 +161,6 @@ fun OpenClimbApp( ) } - // Clear the shortcut action after processing to prevent repeated execution onShortcutActionProcessed() } } @@ -215,8 +208,6 @@ fun OpenClimbApp( if (gyms.size == 1) { viewModel.startSession(context, gyms.first().id) } else { - // Always show gym selection for FAB when - // multiple gyms navController.navigate(Screen.AddEditSession()) } } @@ -362,7 +353,6 @@ fun OpenClimbApp( } } - // Notification permission dialog if (showNotificationPermissionDialog) { NotificationPermissionDialog( onDismiss = { showNotificationPermissionDialog = false }, @@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) { selected = isSelected, onClick = { navController.navigate(item.screen) { - // Clear the entire back stack and go to the selected tab's root screen popUpTo(0) { inclusive = true } - // Avoid multiple copies of the same destination when - // reselecting the same item launchSingleTop = true // Don't restore state - always start fresh when switching tabs restoreState = false diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt index 01b2732..cf2f7d2 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/components/BarChart.kt @@ -54,15 +54,15 @@ fun BarChart( val chartWidth = size.width - padding * 2 val chartHeight = size.height - padding * 2 - // Sort data by grade numeric value for proper ordering + // Sort data by grade numeric value val sortedData = data.sortedBy { it.gradeNumeric } - // Calculate max value for scaling + // Calculate max value val maxValue = sortedData.maxOfOrNull { it.value } ?: 1 - // Calculate bar dimensions + // Bar dimensions val barCount = sortedData.size - val totalSpacing = chartWidth * 0.2f // 20% of width for spacing + val totalSpacing = chartWidth * 0.2f val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2 val barWidth = (chartWidth - totalSpacing) / barCount @@ -106,25 +106,25 @@ fun BarChart( size = androidx.compose.ui.geometry.Size(barWidth, barHeight) ) - // Draw value on top of bar (if there's space) + // Draw value on bar if (dataPoint.value > 0) { val valueText = dataPoint.value.toString() val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp) val textSize = textMeasurer.measure(valueText, textStyle) - // Position text on top of bar or inside if bar is tall enough + // Position text val textY = if (barHeight > textSize.size.height + 8.dp.toPx()) { - barY + 8.dp.toPx() // Inside bar + barY + 8.dp.toPx() } else { - barY - 4.dp.toPx() // Above bar + barY - 4.dp.toPx() } val textColor = if (barHeight > textSize.size.height + 8.dp.toPx()) { - Color.White // White text inside bar + Color.White } else { - style.textColor // Regular color above bar + style.textColor } drawText( @@ -166,7 +166,7 @@ private fun DrawScope.drawGrid( ) { val textStyle = TextStyle(color = textColor, fontSize = 10.sp) - // Draw horizontal grid lines (Y-axis) + // Horizontal grid lines val gridLines = when { maxValue <= 5 -> (0..maxValue).toList() diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt index d7efbb9..17058fe 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt @@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter object DateFormatUtils { - /** - * ISO 8601 formatter matching iOS date format exactly Produces dates like: - * "2025-09-07T22:00:40.014Z" - */ + // ISO 8601 formatter matching iOS date format exactly private val ISO_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC) - /** - * Get current timestamp in iOS-compatible ISO 8601 format - * @return Current timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Get current timestamp in iOS-compatible ISO 8601 format */ fun nowISO8601(): String { return ISO_FORMATTER.format(Instant.now()) } - /** - * Format an Instant to iOS-compatible ISO 8601 format - * @param instant The instant to format - * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Format an Instant to iOS-compatible ISO 8601 format */ fun formatISO8601(instant: Instant): String { return ISO_FORMATTER.format(instant) } - /** - * Parse an iOS-compatible ISO 8601 date string back to Instant - * @param dateString ISO 8601 formatted date string - * @return Instant object, or null if parsing fails - */ + /** Parse an iOS-compatible ISO 8601 date string back to Instant */ fun parseISO8601(dateString: String): Instant? { return try { Instant.from(ISO_FORMATTER.parse(dateString)) } catch (e: Exception) { - // Fallback - try standard Instant parsing + try { Instant.parse(dateString) } catch (e2: Exception) { @@ -48,20 +34,12 @@ object DateFormatUtils { } } - /** - * Validate that a date string matches the expected iOS format - * @param dateString The date string to validate - * @return True if the format matches iOS expectations - */ + /** Validate that a date string matches the expected iOS format */ fun isValidISO8601(dateString: String): Boolean { return parseISO8601(dateString) != null } - /** - * Convert milliseconds timestamp to iOS-compatible ISO 8601 format - * @param millis Milliseconds since epoch - * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" - */ + /** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */ fun millisToISO8601(millis: Long): String { return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt index 94eac0f..73c907b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt @@ -12,15 +12,7 @@ object ImageNamingUtils { private const val IMAGE_EXTENSION = ".jpg" private const val HASH_LENGTH = 12 // First 12 chars of SHA-256 - /** - * Generates a deterministic filename for a problem image. Format: - * "problem_{problemId}_{timestamp}_{index}.jpg" - * - * @param problemId The ID of the problem this image belongs to - * @param timestamp ISO8601 timestamp when the image was created - * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) - * @return A consistent filename that will be the same across platforms - */ + /** Generates a deterministic filename for a problem image */ fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { // Create a deterministic hash from problemId + timestamp + index val input = "${problemId}_${timestamp}_${imageIndex}" @@ -29,25 +21,13 @@ object ImageNamingUtils { return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}" } - /** - * Generates a deterministic filename for a problem image using current timestamp. - * - * @param problemId The ID of the problem this image belongs to - * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) - * @return A consistent filename - */ + /** Generates a deterministic filename using current timestamp */ fun generateImageFilename(problemId: String, imageIndex: Int): String { val timestamp = DateFormatUtils.nowISO8601() return generateImageFilename(problemId, timestamp, imageIndex) } - /** - * Extracts problem ID from an image filename created by this utility. Returns null if the - * filename doesn't match our naming convention. - * - * @param filename The image filename - * @return The problem ID or null if not a valid filename - */ + /** Extracts problem ID from an image filename */ fun extractProblemIdFromFilename(filename: String): String? { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { return null @@ -66,12 +46,7 @@ object ImageNamingUtils { return parts[1] // Return the hash as identifier } - /** - * Validates if a filename follows our naming convention. - * - * @param filename The filename to validate - * @return true if it matches our convention, false otherwise - */ + /** Validates if a filename follows our naming convention */ fun isValidImageFilename(filename: String): Boolean { if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { return false @@ -86,15 +61,7 @@ object ImageNamingUtils { parts[2].toIntOrNull() != null } - /** - * Migrates an existing UUID-based filename to our naming convention. This is used during sync - * to rename downloaded images. - * - * @param oldFilename The existing filename (UUID-based) - * @param problemId The problem ID this image belongs to - * @param imageIndex The index of this image - * @return The new filename following our convention - */ + /** Migrates an existing filename to our naming convention */ fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { // If it's already using our convention, keep it if (isValidImageFilename(oldFilename)) { @@ -107,13 +74,7 @@ object ImageNamingUtils { return generateImageFilename(problemId, timestamp, imageIndex) } - /** - * Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters - * for filename safety. - * - * @param input The input string to hash - * @return First 12 characters of SHA-256 hash in lowercase - */ + /** Creates a deterministic hash from input string */ private fun createHash(input: String): String { val digest = MessageDigest.getInstance("SHA-256") val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) @@ -121,14 +82,7 @@ object ImageNamingUtils { return hashHex.take(HASH_LENGTH) } - /** - * Batch renames images for a problem to use our naming convention. Returns a mapping of old - * filename -> new filename. - * - * @param problemId The problem ID - * @param existingFilenames List of current image filenames for this problem - * @return Map of old filename to new filename - */ + /** Batch renames images for a problem to use our naming convention */ fun batchRenameForProblem( problemId: String, existingFilenames: List diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt index 78fb3cd..47a245d 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -16,7 +16,7 @@ object ImageUtils { private const val MAX_IMAGE_SIZE = 1024 private const val IMAGE_QUALITY = 85 - /** Creates the images directory if it doesn't exist */ + // Creates the images directory if it doesn't exist private fun getImagesDirectory(context: Context): File { val imagesDir = File(context.filesDir, IMAGES_DIR) if (!imagesDir.exists()) { @@ -25,14 +25,7 @@ object ImageUtils { return imagesDir } - /** - * Saves an image from a URI with compression and proper orientation - * @param context Android context - * @param imageUri URI of the image to save - * @param problemId The problem ID this image belongs to (optional) - * @param imageIndex The index of this image for the problem (optional) - * @return The relative file path if successful, null otherwise - */ + /** Saves an image from a URI with compression and proper orientation */ fun saveImageFromUri( context: Context, imageUri: Uri, @@ -40,7 +33,7 @@ object ImageUtils { imageIndex: Int? = null ): String? { return try { - // Decode bitmap from a fresh stream to avoid mark/reset dependency + val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input -> BitmapFactory.decodeStream(input) @@ -50,7 +43,6 @@ object ImageUtils { val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val compressedBitmap = compressImage(orientedBitmap) - // Generate filename using naming convention if problem info provided val filename = if (problemId != null && imageIndex != null) { ImageNamingUtils.generateImageFilename(problemId, imageIndex) @@ -59,19 +51,16 @@ object ImageUtils { } val imageFile = File(getImagesDirectory(context), filename) - // Save compressed image FileOutputStream(imageFile).use { output -> compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) } - // Clean up bitmaps originalBitmap.recycle() if (orientedBitmap != originalBitmap) { orientedBitmap.recycle() } compressedBitmap.recycle() - // Return relative path "$IMAGES_DIR/$filename" } catch (e: Exception) { e.printStackTrace() @@ -162,12 +151,7 @@ object ImageUtils { } } - /** - * Gets the full file path for an image - * @param context Android context - * @param relativePath The relative path returned by saveImageFromUri - * @return Full file path - */ + /** Gets the full file path for an image */ fun getImageFile(context: Context, relativePath: String): File { // If relativePath already contains the directory, use it as-is // Otherwise, assume it's just a filename and add the images directory @@ -179,12 +163,7 @@ object ImageUtils { } } - /** - * Deletes an image file - * @param context Android context - * @param relativePath The relative path of the image to delete - * @return true if deleted successfully, false otherwise - */ + /** Deletes an image file */ fun deleteImage(context: Context, relativePath: String): Boolean { return try { val file = getImageFile(context, relativePath) @@ -195,12 +174,7 @@ object ImageUtils { } } - /** - * Imports an image file from the import directory - * @param context Android context - * @param sourceFile The source image file to import - * @return The relative path in app storage, null if failed - */ + /** Imports an image file from the import directory */ fun importImageFile(context: Context, sourceFile: File): String? { return try { if (!sourceFile.exists()) return null @@ -218,11 +192,7 @@ object ImageUtils { } } - /** - * Gets all image files in the images directory - * @param context Android context - * @return List of relative paths for all images - */ + /** Gets all image files in the images directory */ fun getAllImages(context: Context): List { return try { val imagesDir = getImagesDirectory(context) @@ -242,12 +212,7 @@ object ImageUtils { } } - /** - * Saves an image from byte array to app's private storage - * @param context Android context - * @param imageData Byte array of the image data - * @return The relative file path if successful, null otherwise - */ + /** Saves an image from byte array to app's private storage */ fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { return try { val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null @@ -275,13 +240,7 @@ object ImageUtils { } } - /** - * Saves image data with a specific filename (used for sync to preserve server filenames) - * @param context Android context - * @param imageData The image data as byte array - * @param filename The specific filename to use (including extension) - * @return The relative file path if successful, null otherwise - */ + /** Saves image data with a specific filename */ fun saveImageFromBytesWithFilename( context: Context, imageData: ByteArray, @@ -312,13 +271,7 @@ object ImageUtils { } } - /** - * Migrates existing images to use consistent naming convention - * @param context Android context - * @param problemId The problem ID these images belong to - * @param currentImagePaths List of current image paths for this problem - * @return Map of old path -> new path for successfully migrated images - */ + /** Migrates existing images to use consistent naming convention */ fun migrateImageNaming( context: Context, problemId: String, @@ -349,12 +302,7 @@ object ImageUtils { return migrationMap } - /** - * Batch migrates all images in the system to use consistent naming - * @param context Android context - * @param problemImageMap Map of problem ID -> list of current image paths - * @return Map of old path -> new path for all migrated images - */ + /** Batch migrates all images in the system to use consistent naming */ fun batchMigrateAllImages( context: Context, problemImageMap: Map> @@ -369,11 +317,7 @@ object ImageUtils { return allMigrations } - /** - * Cleans up orphaned images that are not referenced by any problems - * @param context Android context - * @param referencedPaths Set of image paths that are still being used - */ + /** Cleans up orphaned images that are not referenced by any problems */ fun cleanupOrphanedImages(context: Context, referencedPaths: Set) { try { val allImages = getAllImages(context) diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt index a8ee1a6..6456606 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -20,14 +20,7 @@ object ZipExportImportUtils { private const val IMAGES_DIR_NAME = "images" private const val METADATA_FILENAME = "metadata.txt" - /** - * Creates a ZIP file containing the JSON data and all referenced images - * @param context Android context - * @param exportData The data to export (should be serializable) - * @param referencedImagePaths Set of image paths referenced in the data - * @param directory Optional directory to save to, uses default if null - * @return The created ZIP file - */ + /** Creates a ZIP file containing the JSON data and all referenced images */ fun createExportZip( context: Context, exportData: ClimbDataBackup, @@ -120,13 +113,7 @@ object ZipExportImportUtils { } } - /** - * Creates a ZIP file and writes it to a provided URI - * @param context Android context - * @param uri The URI to write to - * @param exportData The data to export - * @param referencedImagePaths Set of image paths referenced in the data - */ + /** Creates a ZIP file and writes it to a provided URI */ fun createExportZipToUri( context: Context, uri: android.net.Uri, @@ -214,12 +201,7 @@ object ZipExportImportUtils { val importedImagePaths: Map // original filename -> new relative path ) - /** - * Extracts a ZIP file and returns the JSON content and imported image paths - * @param context Android context - * @param zipFile The ZIP file to extract - * @return ImportResult containing the JSON and image path mappings - */ + /** Extracts a ZIP file and returns the JSON content and imported image paths */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" val importedImagePaths = mutableMapOf() diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift index a0dcb3c..0cacb0f 100644 --- a/ios/OpenClimb/Models/BackupFormat.swift +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -4,9 +4,6 @@ import Foundation // MARK: - Backup Format Specification v2.0 -// Platform-neutral backup format for cross-platform compatibility -// This format ensures portability between iOS and Android while maintaining -// platform-specific implementations /// Root structure for OpenClimb backup data struct ClimbDataBackup: Codable { @@ -37,7 +34,7 @@ struct ClimbDataBackup: Codable { } } -/// Platform-neutral gym representation for backup/restore +// Platform-neutral gym representation for backup/restore struct BackupGym: Codable { let id: String let name: String @@ -46,8 +43,8 @@ struct BackupGym: Codable { let difficultySystems: [DifficultySystem] let customDifficultyGrades: [String] let notes: String? - let createdAt: String // ISO 8601 format - let updatedAt: String // ISO 8601 format + let createdAt: String + let updatedAt: String /// Initialize from native iOS Gym model init(from gym: Gym) { @@ -114,7 +111,7 @@ struct BackupGym: Codable { } } -/// Platform-neutral problem representation for backup/restore +// Platform-neutral problem representation for backup/restore struct BackupProblem: Codable { let id: String let gymId: String @@ -128,8 +125,8 @@ struct BackupProblem: Codable { let isActive: Bool let dateSet: String? // ISO 8601 format let notes: String? - let createdAt: String // ISO 8601 format - let updatedAt: String // ISO 8601 format + let createdAt: String + let updatedAt: String /// Initialize from native iOS Problem model init(from problem: Problem) { @@ -239,7 +236,7 @@ struct BackupProblem: Codable { } } -/// Platform-neutral climb session representation for backup/restore +// Platform-neutral climb session representation for backup/restore struct BackupClimbSession: Codable { let id: String let gymId: String @@ -249,8 +246,8 @@ struct BackupClimbSession: Codable { let duration: Int64? // Duration in seconds let status: SessionStatus let notes: String? - let createdAt: String // ISO 8601 format - let updatedAt: String // ISO 8601 format + let createdAt: String + let updatedAt: String /// Initialize from native iOS ClimbSession model init(from session: ClimbSession) { @@ -327,7 +324,7 @@ struct BackupClimbSession: Codable { } } -/// Platform-neutral attempt representation for backup/restore +// Platform-neutral attempt representation for backup/restore struct BackupAttempt: Codable { let id: String let sessionId: String @@ -337,8 +334,8 @@ struct BackupAttempt: Codable { let notes: String? let duration: Int64? // Duration in seconds let restTime: Int64? // Rest time in seconds - let timestamp: String // ISO 8601 format - let createdAt: String // ISO 8601 format + let timestamp: String + let createdAt: String /// Initialize from native iOS Attempt model init(from attempt: Attempt) { diff --git a/ios/OpenClimb/Utils/DataStateManager.swift b/ios/OpenClimb/Utils/DataStateManager.swift index b0623cc..e7ff5ea 100644 --- a/ios/OpenClimb/Utils/DataStateManager.swift +++ b/ios/OpenClimb/Utils/DataStateManager.swift @@ -3,8 +3,7 @@ import Foundation -/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the -/// local database was last modified, independent of individual entity timestamps. +/// Manages the overall data state timestamp for sync purposes class DataStateManager { private let userDefaults = UserDefaults.standard @@ -14,7 +13,6 @@ class DataStateManager { static let initialized = "openclimb_data_state_initialized" } - /// Shared instance for app-wide use static let shared = DataStateManager() private init() { diff --git a/ios/OpenClimb/Utils/ImageNamingUtils.swift b/ios/OpenClimb/Utils/ImageNamingUtils.swift index 8aca2d1..b15eabb 100644 --- a/ios/OpenClimb/Utils/ImageNamingUtils.swift +++ b/ios/OpenClimb/Utils/ImageNamingUtils.swift @@ -4,54 +4,36 @@ import CryptoKit import Foundation -/// Utility for creating consistent image filenames across iOS and Android platforms. -/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility. +/// Utility for creating consistent image filenames across platforms class ImageNamingUtils { private static let imageExtension = ".jpg" - private static let hashLength = 12 // First 12 chars of SHA-256 + private static let hashLength = 12 - /// Generates a deterministic filename for a problem image. - /// Format: "problem_{hash}_{index}.jpg" - /// - /// - Parameters: - /// - problemId: The ID of the problem this image belongs to - /// - timestamp: ISO8601 timestamp when the image was created - /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) - /// - Returns: A consistent filename that will be the same across platforms + /// Generates a deterministic filename for a problem image static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int) -> String { - // Create a deterministic hash from problemId + timestamp + index + let input = "\(problemId)_\(timestamp)_\(imageIndex)" let hash = createHash(from: input) return "problem_\(hash)_\(imageIndex)\(imageExtension)" } - /// Generates a deterministic filename for a problem image using current timestamp. - /// - /// - Parameters: - /// - problemId: The ID of the problem this image belongs to - /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) - /// - Returns: A consistent filename + /// Generates a deterministic filename using current timestamp static func generateImageFilename(problemId: String, imageIndex: Int) -> String { let timestamp = ISO8601DateFormatter().string(from: Date()) return generateImageFilename( problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) } - /// Extracts problem ID from an image filename created by this utility. - /// Returns nil if the filename doesn't match our naming convention. - /// - /// - Parameter filename: The image filename - /// - Returns: The hash identifier or nil if not a valid filename + /// Extracts problem ID from an image filename static func extractProblemIdFromFilename(_ filename: String) -> String? { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { return nil } - // Format: problem_{hash}_{index}.jpg let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) let parts = nameWithoutExtension.components(separatedBy: "_") @@ -59,14 +41,10 @@ class ImageNamingUtils { return nil } - // Return the hash as identifier return parts[1] } - /// Validates if a filename follows our naming convention. - /// - /// - Parameter filename: The filename to validate - /// - Returns: true if it matches our convention, false otherwise + /// Validates if a filename follows our naming convention static func isValidImageFilename(_ filename: String) -> Bool { guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { return false @@ -79,32 +57,19 @@ class ImageNamingUtils { && Int(parts[2]) != nil } - /// Migrates an existing UUID-based filename to our naming convention. - /// This is used during sync to rename downloaded images. - /// - /// - Parameters: - /// - oldFilename: The existing filename (UUID-based) - /// - problemId: The problem ID this image belongs to - /// - imageIndex: The index of this image - /// - Returns: The new filename following our convention + /// Migrates an existing filename to our naming convention static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String { - // If it's already using our convention, keep it + if isValidImageFilename(oldFilename) { return oldFilename } - // Generate new deterministic name - // Use current timestamp to maintain some consistency let timestamp = ISO8601DateFormatter().string(from: Date()) return generateImageFilename( problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) } - /// Creates a deterministic hash from input string. - /// Uses SHA-256 and takes first 12 characters for filename safety. - /// - /// - Parameter input: The input string to hash - /// - Returns: First 12 characters of SHA-256 hash in lowercase + /// Creates a deterministic hash from input string private static func createHash(from input: String) -> String { let inputData = Data(input.utf8) let hashed = SHA256.hash(data: inputData) @@ -112,13 +77,7 @@ class ImageNamingUtils { return String(hashString.prefix(hashLength)) } - /// Batch renames images for a problem to use our naming convention. - /// Returns a mapping of old filename -> new filename. - /// - /// - Parameters: - /// - problemId: The problem ID - /// - existingFilenames: List of current image filenames for this problem - /// - Returns: Dictionary mapping old filename to new filename + /// Batch renames images for a problem to use our naming convention static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String: String] { @@ -135,10 +94,7 @@ class ImageNamingUtils { return renameMap } - /// Validates that a collection of filenames follow our naming convention. - /// - /// - Parameter filenames: Array of filenames to validate - /// - Returns: Dictionary with validation results + /// Validates that a collection of filenames follow our naming convention static func validateFilenames(_ filenames: [String]) -> ImageValidationResult { var validImages: [String] = [] var invalidImages: [String] = [] @@ -159,7 +115,7 @@ class ImageNamingUtils { } } -/// Result of image filename validation +// Result of image filename validation struct ImageValidationResult { let totalImages: Int let validImages: [String]