From c3f847e1e6481fab1865ef90695c821dc70486ed Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sun, 28 Sep 2025 02:37:03 -0600 Subject: [PATCH] 1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import formats :) --- .github/workflows/deploy.yml | 43 + README.md | 14 +- android/app/build.gradle.kts | 6 +- android/app/build.gradle.kts.backup | 98 ++ android/app/build_new.gradle.kts | 98 ++ android/app/src/main/AndroidManifest.xml | 3 + .../openclimb/data/format/BackupFormat.kt | 233 ++++ .../data/migration/ImageMigrationService.kt | 205 ++++ .../atridad/openclimb/data/model/Attempt.kt | 96 +- .../openclimb/data/model/ClimbSession.kt | 101 +- .../openclimb/data/model/DifficultySystem.kt | 244 +++-- .../com/atridad/openclimb/data/model/Gym.kt | 53 +- .../atridad/openclimb/data/model/Problem.kt | 4 +- .../data/repository/ClimbRepository.kt | 263 +++-- .../openclimb/data/state/DataStateManager.kt | 81 ++ .../openclimb/data/sync/SyncService.kt | 998 ++++++++++++++++++ .../com/atridad/openclimb/ui/OpenClimbApp.kt | 9 +- .../openclimb/ui/screens/SettingsScreen.kt | 961 ++++++++++++----- .../openclimb/ui/viewmodel/ClimbViewModel.kt | 25 +- .../ui/viewmodel/ClimbViewModelFactory.kt | 8 +- .../openclimb/utils/DateFormatUtils.kt | 68 ++ .../openclimb/utils/ImageNamingUtils.kt | 147 +++ .../com/atridad/openclimb/utils/ImageUtils.kt | 254 ++++- .../openclimb/utils/ZipExportImportUtils.kt | 223 ++-- .../atridad/openclimb/SyncMergeLogicTest.kt | 451 ++++++++ android/gradle/libs.versions.toml | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 554 bytes android/test_backup/ClimbRepository.kt | 383 +++++++ ios/OpenClimb.xcodeproj/project.pbxproj | 18 +- .../UserInterfaceState.xcuserstate | Bin 115423 -> 124606 bytes .../xcshareddata/xcschemes/OpenClimb.xcscheme | 78 ++ ios/OpenClimb/ContentView.swift | 6 +- ios/OpenClimb/Models/BackupFormat.swift | 447 ++++++++ ios/OpenClimb/Services/SyncService.swift | 978 +++++++++++++++++ ios/OpenClimb/Utils/DataStateManager.swift | 85 ++ ios/OpenClimb/Utils/ImageNamingUtils.swift | 176 +++ ios/OpenClimb/Utils/ZipUtils.swift | 5 +- .../ViewModels/ClimbingDataManager.swift | 387 +------ .../ViewModels/LiveActivityManager.swift | 10 +- ios/OpenClimb/Views/SessionsView.swift | 12 +- ios/OpenClimb/Views/SettingsView.swift | 358 +++++++ sync/.env.example | 14 + sync/.gitignore | 16 + sync/Dockerfile | 14 + sync/docker-compose.yml | 12 + sync/go.mod | 3 + sync/main.go | 358 +++++++ sync/version.md | 1 + 48 files changed, 6944 insertions(+), 1107 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 android/app/build.gradle.kts.backup create mode 100644 android/app/build_new.gradle.kts create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt create mode 100644 android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt create mode 100644 android/test_backup/ClimbRepository.kt create mode 100644 ios/OpenClimb/Models/BackupFormat.swift create mode 100644 ios/OpenClimb/Services/SyncService.swift create mode 100644 ios/OpenClimb/Utils/DataStateManager.swift create mode 100644 ios/OpenClimb/Utils/ImageNamingUtils.swift create mode 100644 sync/.env.example create mode 100644 sync/.gitignore create mode 100644 sync/Dockerfile create mode 100644 sync/docker-compose.yml create mode 100644 sync/go.mod create mode 100644 sync/main.go create mode 100644 sync/version.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..1ae4138 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,43 @@ +name: OpenClimb Docker Deploy +on: + push: + branches: [main] + paths: + - "sync/**" + - ".github/workflows/deploy.yml" + pull_request: + branches: [main] + paths: + - "sync/**" + - ".github/workflows/deploy.yml" + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.REPO_HOST }} + username: ${{ github.repository_owner }} + password: ${{ secrets.DEPLOY_TOKEN }} + + - name: Build and push sync-server + uses: docker/build-push-action@v4 + with: + context: ./sync + file: ./sync/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }} + ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest diff --git a/README.md b/README.md index b4df681..861e258 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,18 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems, ## Versions -- Android:1.4.2 -- iOS: 1.0.1 +- 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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1b83931..a169fc6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 26 - versionName = "1.5.1" + versionCode = 28 + versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -92,4 +92,6 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/android/app/build.gradle.kts.backup b/android/app/build.gradle.kts.backup new file mode 100644 index 0000000..3d33435 --- /dev/null +++ b/android/app/build.gradle.kts.backup @@ -0,0 +1,98 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.atridad.openclimb" + compileSdk = 36 + + defaultConfig { + applicationId = "com.atridad.openclimb" + minSdk = 31 + targetSdk = 36 + versionCode = 27 + versionName = "1.6.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } + + buildFeatures { compose = true } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } + +dependencies { + // Core Android libraries + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM and UI + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/android/app/build_new.gradle.kts b/android/app/build_new.gradle.kts new file mode 100644 index 0000000..5fec1e4 --- /dev/null +++ b/android/app/build_new.gradle.kts @@ -0,0 +1,98 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.atridad.openclimb" + compileSdk = 36 + + defaultConfig { + applicationId = "com.atridad.openclimb" + minSdk = 31 + targetSdk = 36 + versionCode = 27 + versionName = "1.6.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } + + buildFeatures { compose = true } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } + +dependencies { + // Core Android libraries + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM and UI + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + + // HTTP Client + implementation(libs.okhttp) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5af5fc4..c187fce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ android:maxSdkVersion="28" /> + + + 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 new file mode 100644 index 0000000..f8fae25 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -0,0 +1,233 @@ +package com.atridad.openclimb.data.format + +import com.atridad.openclimb.data.model.* +import kotlinx.serialization.Serializable + +/** Root structure for OpenClimb backup data */ +@Serializable +data class ClimbDataBackup( + val exportedAt: String, + val version: String = "2.0", + val formatVersion: String = "2.0", + val gyms: List, + val problems: List, + val sessions: List, + val attempts: List +) + +/** Platform-neutral gym representation for backup/restore */ +@Serializable +data class BackupGym( + val id: String, + val name: String, + val location: String? = null, + val supportedClimbTypes: List, + val difficultySystems: List, + @kotlinx.serialization.SerialName("customDifficultyGrades") + val customDifficultyGrades: List = emptyList(), + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupGym from native Android Gym model */ + fun fromGym(gym: Gym): BackupGym { + return BackupGym( + id = gym.id, + name = gym.name, + location = gym.location, + supportedClimbTypes = gym.supportedClimbTypes, + difficultySystems = gym.difficultySystems, + customDifficultyGrades = gym.customDifficultyGrades, + notes = gym.notes, + createdAt = gym.createdAt, + updatedAt = gym.updatedAt + ) + } + } + + /** Convert to native Android Gym model */ + fun toGym(): Gym { + return Gym( + id = id, + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral problem representation for backup/restore */ +@Serializable +data class BackupProblem( + val id: String, + val gymId: String, + val name: String? = null, + val description: String? = null, + val climbType: ClimbType, + val difficulty: DifficultyGrade, + val tags: List = emptyList(), + val location: String? = null, + val imagePaths: List? = null, + val isActive: Boolean = true, + val dateSet: String? = null, // ISO 8601 format + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupProblem from native Android Problem model */ + fun fromProblem(problem: Problem): BackupProblem { + return BackupProblem( + id = problem.id, + gymId = problem.gymId, + name = problem.name, + description = problem.description, + climbType = problem.climbType, + difficulty = problem.difficulty, + tags = problem.tags, + location = problem.location, + imagePaths = + if (problem.imagePaths.isEmpty()) null + else + problem.imagePaths.map { path -> + // Store just the filename to match iOS format + path.substringAfterLast('/') + }, + isActive = problem.isActive, + dateSet = problem.dateSet, + notes = problem.notes, + createdAt = problem.createdAt, + updatedAt = problem.updatedAt + ) + } + } + + /** Convert to native Android Problem model */ + fun toProblem(): Problem { + return Problem( + id = id, + gymId = gymId, + name = name, + description = description, + climbType = climbType, + difficulty = difficulty, + tags = tags, + location = location, + imagePaths = imagePaths ?: emptyList(), + isActive = isActive, + dateSet = dateSet, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } + + /** Create a copy with updated image paths for import processing */ + fun withUpdatedImagePaths(newImagePaths: List): BackupProblem { + return copy(imagePaths = newImagePaths.ifEmpty { null }) + } +} + +/** 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 status: SessionStatus, + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupClimbSession from native Android ClimbSession model */ + fun fromClimbSession(session: ClimbSession): BackupClimbSession { + return BackupClimbSession( + id = session.id, + gymId = session.gymId, + date = session.date, + startTime = session.startTime, + endTime = session.endTime, + duration = session.duration, + status = session.status, + notes = session.notes, + createdAt = session.createdAt, + updatedAt = session.updatedAt + ) + } + } + + /** Convert to native Android ClimbSession model */ + fun toClimbSession(): ClimbSession { + return ClimbSession( + id = id, + gymId = gymId, + date = date, + startTime = startTime, + endTime = endTime, + duration = duration, + status = status, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral attempt representation for backup/restore */ +@Serializable +data class BackupAttempt( + val id: String, + val sessionId: String, + val problemId: String, + 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 +) { + companion object { + /** Create BackupAttempt from native Android Attempt model */ + fun fromAttempt(attempt: Attempt): BackupAttempt { + return BackupAttempt( + id = attempt.id, + sessionId = attempt.sessionId, + problemId = attempt.problemId, + result = attempt.result, + highestHold = attempt.highestHold, + notes = attempt.notes, + duration = attempt.duration, + restTime = attempt.restTime, + timestamp = attempt.timestamp, + createdAt = attempt.createdAt + ) + } + } + + /** Convert to native Android Attempt model */ + fun toAttempt(): Attempt { + return Attempt( + id = id, + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = createdAt + ) + } +} 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 new file mode 100644 index 0000000..0ec8afc --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt @@ -0,0 +1,205 @@ +package com.atridad.openclimb.data.migration + +import android.content.Context +import android.util.Log +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.utils.ImageNamingUtils +import com.atridad.openclimb.utils.ImageUtils +import kotlinx.coroutines.flow.first + +/** + * Service responsible for migrating images to use consistent naming convention across platforms. + * This ensures that iOS and Android use the same image filenames for sync compatibility. + */ +class ImageMigrationService(private val context: Context, private val repository: ClimbRepository) { + companion object { + private const val TAG = "ImageMigrationService" + private const val MIGRATION_PREF_KEY = "image_naming_migration_completed" + } + + /** + * Performs a complete migration of all images in the system to use consistent naming. This + * should be called once during app startup after the naming convention is implemented. + */ + suspend fun performFullMigration(): ImageMigrationResult { + Log.i(TAG, "Starting full image naming migration") + + val prefs = context.getSharedPreferences("openclimb_migration", Context.MODE_PRIVATE) + if (prefs.getBoolean(MIGRATION_PREF_KEY, false)) { + Log.i(TAG, "Image migration already completed, skipping") + return ImageMigrationResult.AlreadyCompleted + } + + try { + val allProblems = repository.getAllProblems().first() + val migrationResults = mutableMapOf() + var migratedCount = 0 + var errorCount = 0 + + Log.i(TAG, "Found ${allProblems.size} problems to check for image migration") + + for (problem in allProblems) { + if (problem.imagePaths.isNotEmpty()) { + Log.d( + TAG, + "Migrating images for problem '${problem.name}': ${problem.imagePaths}" + ) + + try { + val problemMigrations = + ImageUtils.migrateImageNaming( + context = context, + problemId = problem.id, + currentImagePaths = problem.imagePaths + ) + + if (problemMigrations.isNotEmpty()) { + migrationResults.putAll(problemMigrations) + migratedCount += problemMigrations.size + + // Update problem with new image paths + val newImagePaths = + problem.imagePaths.map { oldPath -> + problemMigrations[oldPath] ?: oldPath + } + + val updatedProblem = problem.copy(imagePaths = newImagePaths) + repository.insertProblem(updatedProblem) + + Log.d( + TAG, + "Updated problem '${problem.name}' with ${problemMigrations.size} migrated images" + ) + } + } catch (e: Exception) { + Log.e( + TAG, + "Failed to migrate images for problem '${problem.name}': ${e.message}", + e + ) + errorCount++ + } + } + } + + // Mark migration as completed + prefs.edit().putBoolean(MIGRATION_PREF_KEY, true).apply() + + Log.i( + TAG, + "Image migration completed: $migratedCount images migrated, $errorCount errors" + ) + + return ImageMigrationResult.Success( + totalMigrated = migratedCount, + errors = errorCount, + migrations = migrationResults + ) + } catch (e: Exception) { + Log.e(TAG, "Image migration failed: ${e.message}", e) + return ImageMigrationResult.Failed(e.message ?: "Unknown error") + } + } + + /** Validates that all images in the system follow the consistent naming convention. */ + suspend fun validateImageNaming(): ValidationResult { + try { + val allProblems = repository.getAllProblems().first() + val validImages = mutableListOf() + val invalidImages = mutableListOf() + val missingImages = mutableListOf() + + for (problem in allProblems) { + for (imagePath in problem.imagePaths) { + val filename = imagePath.substringAfterLast('/') + + // Check if file exists + val imageFile = ImageUtils.getImageFile(context, imagePath) + if (!imageFile.exists()) { + missingImages.add(imagePath) + continue + } + + // Check if filename follows our convention + if (ImageNamingUtils.isValidImageFilename(filename)) { + validImages.add(imagePath) + } else { + invalidImages.add(imagePath) + } + } + } + + return ValidationResult( + totalImages = validImages.size + invalidImages.size + missingImages.size, + validImages = validImages, + invalidImages = invalidImages, + missingImages = missingImages + ) + } catch (e: Exception) { + Log.e(TAG, "Image validation failed: ${e.message}", e) + return ValidationResult( + totalImages = 0, + validImages = emptyList(), + invalidImages = emptyList(), + missingImages = emptyList() + ) + } + } + + /** Migrates images for a specific problem during sync operations. */ + suspend fun migrateProblemImages( + problemId: String, + currentImagePaths: List + ): Map { + return try { + ImageUtils.migrateImageNaming(context, problemId, currentImagePaths) + } catch (e: Exception) { + Log.e(TAG, "Failed to migrate images for problem $problemId: ${e.message}", e) + emptyMap() + } + } + + /** + * Cleans up any orphaned image files that don't follow our naming convention and aren't + * referenced by any problems. + */ + suspend fun cleanupOrphanedImages() { + try { + val allProblems = repository.getAllProblems().first() + val referencedPaths = allProblems.flatMap { it.imagePaths }.toSet() + + ImageUtils.cleanupOrphanedImages(context, referencedPaths) + + Log.i(TAG, "Orphaned image cleanup completed") + } catch (e: Exception) { + Log.e(TAG, "Failed to cleanup orphaned images: ${e.message}", e) + } + } +} + +/** Result of an image migration operation */ +sealed class ImageMigrationResult { + object AlreadyCompleted : ImageMigrationResult() + + data class Success( + val totalMigrated: Int, + val errors: Int, + val migrations: Map + ) : ImageMigrationResult() + + data class Failed(val error: String) : ImageMigrationResult() +} + +/** Result of image naming validation */ +data class ValidationResult( + val totalImages: Int, + val validImages: List, + val invalidImages: List, + val missingImages: List +) { + val isAllValid: Boolean + get() = invalidImages.isEmpty() && missingImages.isEmpty() + + val validPercentage: Double + get() = if (totalImages == 0) 100.0 else (validImages.size.toDouble() / totalImages) * 100 +} 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 1f93a1c..794cf7d 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 @@ -4,8 +4,8 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable enum class AttemptResult { @@ -16,63 +16,59 @@ enum class AttemptResult { } @Entity( - tableName = "attempts", - foreignKeys = [ - ForeignKey( - entity = ClimbSession::class, - parentColumns = ["id"], - childColumns = ["sessionId"], - onDelete = ForeignKey.CASCADE - ), - ForeignKey( - entity = Problem::class, - parentColumns = ["id"], - childColumns = ["problemId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [ - Index(value = ["sessionId"]), - Index(value = ["problemId"]) - ] + tableName = "attempts", + foreignKeys = + [ + ForeignKey( + entity = ClimbSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Problem::class, + parentColumns = ["id"], + childColumns = ["problemId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])] ) @Serializable data class Attempt( - @PrimaryKey - val id: String, - val sessionId: String, - val problemId: String, - val result: AttemptResult, - val highestHold: String? = null, // Description of the highest hold reached - 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 createdAt: String + @PrimaryKey val id: String, + val sessionId: String, + val problemId: String, + val result: AttemptResult, + val highestHold: String? = null, // Description of the highest hold reached + 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 createdAt: String ) { companion object { fun create( - sessionId: String, - problemId: String, - result: AttemptResult, - highestHold: String? = null, - notes: String? = null, - duration: Long? = null, - restTime: Long? = null, - timestamp: String = LocalDateTime.now().toString() + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String? = null, + notes: String? = null, + duration: Long? = null, + restTime: Long? = null, + timestamp: String = DateFormatUtils.nowISO8601() ): Attempt { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Attempt( - id = java.util.UUID.randomUUID().toString(), - sessionId = sessionId, - problemId = problemId, - result = result, - highestHold = highestHold, - notes = notes, - duration = duration, - restTime = restTime, - timestamp = timestamp, - createdAt = now + id = java.util.UUID.randomUUID().toString(), + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = now ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt index f3b9c7d..0c4b5c9 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt @@ -4,8 +4,8 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable enum class SessionStatus { @@ -15,66 +15,65 @@ enum class SessionStatus { } @Entity( - tableName = "climb_sessions", - foreignKeys = [ - ForeignKey( - entity = Gym::class, - parentColumns = ["id"], - childColumns = ["gymId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index(value = ["gymId"])] + tableName = "climb_sessions", + foreignKeys = + [ + ForeignKey( + entity = Gym::class, + parentColumns = ["id"], + childColumns = ["gymId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["gymId"])] ) @Serializable data class ClimbSession( - @PrimaryKey - val id: String, - val gymId: String, - val date: String, - val startTime: String? = null, - val endTime: String? = null, - val duration: Long? = null, - val status: SessionStatus = SessionStatus.ACTIVE, - val notes: String? = null, - val createdAt: String, - val updatedAt: String + @PrimaryKey val id: String, + val gymId: String, + val date: String, + val startTime: String? = null, + val endTime: String? = null, + val duration: Long? = null, + val status: SessionStatus = SessionStatus.ACTIVE, + val notes: String? = null, + val createdAt: String, + val updatedAt: String ) { companion object { - fun create( - gymId: String, - notes: String? = null - ): ClimbSession { - val now = LocalDateTime.now().toString() + fun create(gymId: String, notes: String? = null): ClimbSession { + val now = DateFormatUtils.nowISO8601() return ClimbSession( - id = java.util.UUID.randomUUID().toString(), - gymId = gymId, - date = now, - startTime = now, - status = SessionStatus.ACTIVE, - notes = notes, - createdAt = now, - updatedAt = now + id = java.util.UUID.randomUUID().toString(), + gymId = gymId, + date = now, + startTime = now, + status = SessionStatus.ACTIVE, + notes = notes, + createdAt = now, + updatedAt = now ) } - + fun ClimbSession.complete(): ClimbSession { - val endTime = LocalDateTime.now().toString() - val durationMinutes = if (startTime != null) { - try { - val start = LocalDateTime.parse(startTime) - val end = LocalDateTime.parse(endTime) - java.time.Duration.between(start, end).toMinutes() - } catch (_: Exception) { - null - } - } else null - + val endTime = DateFormatUtils.nowISO8601() + val durationMinutes = + if (startTime != null) { + try { + val start = DateFormatUtils.parseISO8601(startTime) + val end = DateFormatUtils.parseISO8601(endTime) + if (start != null && end != null) { + java.time.Duration.between(start, end).toMinutes() + } else null + } catch (_: Exception) { + null + } + } else null + return this.copy( - endTime = endTime, - duration = durationMinutes, - status = SessionStatus.COMPLETED, - updatedAt = LocalDateTime.now().toString() + endTime = endTime, + duration = durationMinutes, + status = SessionStatus.COMPLETED, + updatedAt = DateFormatUtils.nowISO8601() ) } } 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 835e600..fd58bb9 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 @@ -7,75 +7,199 @@ enum class DifficultySystem { // Bouldering V_SCALE, // V-Scale (VB - V17) FONT, // Fontainebleau (3 - 8C+) - + // Rope YDS, // Yosemite Decimal System (5.0 - 5.15d) - + // Custom difficulty systems CUSTOM; - - /** - * Get the display name for the UI - */ - fun getDisplayName(): String = when (this) { - V_SCALE -> "V Scale" - FONT -> "Font Scale" - YDS -> "YDS (Yosemite)" - CUSTOM -> "Custom" - } - - /** - * Check if this system is for bouldering - */ - fun isBoulderingSystem(): Boolean = when (this) { - V_SCALE, FONT -> true - YDS -> false - CUSTOM -> true // Custom is available for all - } - - /** - * Check if this system is for rope climbing - */ - fun isRopeSystem(): Boolean = when (this) { - YDS -> true - V_SCALE, FONT -> false - CUSTOM -> true - } - - /** - * Get available grades for this system - */ - fun getAvailableGrades(): List = when (this) { - V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17") - FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+") - YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d") - CUSTOM -> emptyList() - } - + + /** Get the display name for the UI */ + fun getDisplayName(): String = + when (this) { + V_SCALE -> "V Scale" + FONT -> "Font Scale" + YDS -> "YDS (Yosemite)" + CUSTOM -> "Custom" + } + + /** Check if this system is for bouldering */ + fun isBoulderingSystem(): Boolean = + when (this) { + V_SCALE, FONT -> true + YDS -> false + CUSTOM -> true // Custom is available for all + } + + /** Check if this system is for rope climbing */ + fun isRopeSystem(): Boolean = + when (this) { + YDS -> true + V_SCALE, FONT -> false + CUSTOM -> true + } + + /** Get available grades for this system */ + fun getAvailableGrades(): List = + when (this) { + V_SCALE -> + listOf( + "VB", + "V0", + "V1", + "V2", + "V3", + "V4", + "V5", + "V6", + "V7", + "V8", + "V9", + "V10", + "V11", + "V12", + "V13", + "V14", + "V15", + "V16", + "V17" + ) + FONT -> + listOf( + "3", + "4A", + "4B", + "4C", + "5A", + "5B", + "5C", + "6A", + "6A+", + "6B", + "6B+", + "6C", + "6C+", + "7A", + "7A+", + "7B", + "7B+", + "7C", + "7C+", + "8A", + "8A+", + "8B", + "8B+", + "8C", + "8C+" + ) + YDS -> + listOf( + "5.0", + "5.1", + "5.2", + "5.3", + "5.4", + "5.5", + "5.6", + "5.7", + "5.8", + "5.9", + "5.10a", + "5.10b", + "5.10c", + "5.10d", + "5.11a", + "5.11b", + "5.11c", + "5.11d", + "5.12a", + "5.12b", + "5.12c", + "5.12d", + "5.13a", + "5.13b", + "5.13c", + "5.13d", + "5.14a", + "5.14b", + "5.14c", + "5.14d", + "5.15a", + "5.15b", + "5.15c", + "5.15d" + ) + CUSTOM -> emptyList() + } + companion object { - /** - * Get all difficulty systems based on type - */ - fun getSystemsForClimbType(climbType: ClimbType): List = when (climbType) { - ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } - ClimbType.ROPE -> entries.filter { it.isRopeSystem() } - } + /** Get all difficulty systems based on type */ + fun getSystemsForClimbType(climbType: ClimbType): List = + when (climbType) { + ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } + ClimbType.ROPE -> entries.filter { it.isRopeSystem() } + } } } @Serializable -data class DifficultyGrade( - val system: DifficultySystem, - val grade: String, - val numericValue: Int -) { +data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) { + + constructor( + system: DifficultySystem, + grade: String + ) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade)) + + companion object { + private fun calculateNumericValue(system: DifficultySystem, grade: String): Int { + return when (system) { + DifficultySystem.V_SCALE -> { + 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) + grade.startsWith("5.11") -> + 14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.12") -> + 18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.13") -> + 22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.14") -> + 26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.15") -> + 30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + else -> grade.removePrefix("5.").toIntOrNull() ?: 0 + } + } + DifficultySystem.FONT -> { + // Simplified Font grade mapping + when { + grade.startsWith("6A") -> 6 + grade.startsWith("6B") -> 7 + grade.startsWith("6C") -> 8 + grade.startsWith("7A") -> 9 + grade.startsWith("7B") -> 10 + grade.startsWith("7C") -> 11 + grade.startsWith("8A") -> 12 + grade.startsWith("8B") -> 13 + grade.startsWith("8C") -> 14 + else -> grade.toIntOrNull() ?: 0 + } + } + DifficultySystem.CUSTOM -> grade.hashCode().rem(100) + } + } + } /** - * Compare this grade with another grade of the same system - * Returns negative if this grade is easier, positive if harder, 0 if equal + * Compare this grade with another grade of the same system Returns negative if this grade is + * easier, positive if harder, 0 if equal */ fun compareTo(other: DifficultyGrade): Int { if (system != other.system) return 0 - + return when (system) { DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade) DifficultySystem.FONT -> compareFontGrades(grade, other.grade) @@ -83,24 +207,24 @@ data class DifficultyGrade( DifficultySystem.CUSTOM -> grade.compareTo(other.grade) } } - + 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/model/Gym.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt index de81001..1876cd9 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt @@ -2,43 +2,42 @@ package com.atridad.openclimb.data.model import androidx.room.Entity import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Entity(tableName = "gyms") @Serializable data class Gym( - @PrimaryKey - val id: String, - val name: String, - val location: String? = null, - val supportedClimbTypes: List, - val difficultySystems: List, - val customDifficultyGrades: List = emptyList(), - val notes: String? = null, - val createdAt: String, - val updatedAt: String + @PrimaryKey val id: String, + val name: String, + val location: String? = null, + val supportedClimbTypes: List, + val difficultySystems: List, + val customDifficultyGrades: List = emptyList(), + val notes: String? = null, + val createdAt: String, + val updatedAt: String ) { companion object { fun create( - name: String, - location: String? = null, - supportedClimbTypes: List, - difficultySystems: List, - customDifficultyGrades: List = emptyList(), - notes: String? = null + name: String, + location: String? = null, + supportedClimbTypes: List, + difficultySystems: List, + customDifficultyGrades: List = emptyList(), + notes: String? = null ): Gym { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Gym( - id = java.util.UUID.randomUUID().toString(), - name = name, - location = location, - supportedClimbTypes = supportedClimbTypes, - difficultySystems = difficultySystems, - customDifficultyGrades = customDifficultyGrades, - notes = notes, - createdAt = now, - updatedAt = now + id = java.util.UUID.randomUUID().toString(), + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = now, + updatedAt = now ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt index 1116f7f..b983d22 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt @@ -4,7 +4,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import java.time.LocalDateTime +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable @Entity( @@ -49,7 +49,7 @@ data class Problem( dateSet: String? = null, notes: String? = null ): Problem { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Problem( id = java.util.UUID.randomUUID().toString(), gymId = gymId, 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 e4d3e0d..01aced1 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 @@ -2,10 +2,16 @@ package com.atridad.openclimb.data.repository import android.content.Context import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.format.BackupAttempt +import com.atridad.openclimb.data.format.BackupClimbSession +import com.atridad.openclimb.data.format.BackupGym +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.data.state.DataStateManager +import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.openclimb.utils.ZipExportImportUtils import java.io.File -import java.time.LocalDateTime import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json @@ -15,6 +21,10 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private val problemDao = database.problemDao() private val sessionDao = database.climbSessionDao() 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 { prettyPrint = true @@ -24,19 +34,41 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Gym operations fun getAllGyms(): Flow> = gymDao.getAllGyms() suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) - suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) - suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) - suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) - fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + suspend fun insertGym(gym: Gym) { + gymDao.insertGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateGym(gym: Gym) { + gymDao.updateGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteGym(gym: Gym) { + gymDao.deleteGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } // Problem operations fun getAllProblems(): Flow> = problemDao.getAllProblems() suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) - suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) - suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) - suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) - fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + suspend fun insertProblem(problem: Problem) { + problemDao.insertProblem(problem) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateProblem(problem: Problem) { + problemDao.updateProblem(problem) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteProblem(problem: Problem) { + problemDao.deleteProblem(problem) + dataStateManager.updateDataState() + triggerAutoSync() + } // Session operations fun getAllSessions(): Flow> = sessionDao.getAllSessions() @@ -45,9 +77,21 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) sessionDao.getSessionsByGym(gymId) suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() - suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) - suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) - suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) + suspend fun insertSession(session: ClimbSession) { + sessionDao.insertSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateSession(session: ClimbSession) { + sessionDao.updateSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteSession(session: ClimbSession) { + sessionDao.deleteSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } suspend fun getLastUsedGym(): Gym? { val recentSessions = sessionDao.getRecentSessions(1).first() return if (recentSessions.isNotEmpty()) { @@ -63,73 +107,25 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) attemptDao.getAttemptsBySession(sessionId) fun getAttemptsByProblem(problemId: String): Flow> = attemptDao.getAttemptsByProblem(problemId) - suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) - suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) - suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) - - // ZIP Export with images - Single format for reliability - suspend fun exportAllDataToZip(directory: File? = null): File { - try { - // Collect all data with proper error handling - 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) - - val exportData = - ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - version = "2.0", - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts - ) - - // Collect all referenced image paths and validate they exist - val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() - val validImagePaths = - referencedImagePaths - .filter { imagePath -> - try { - val imageFile = - com.atridad.openclimb.utils.ImageUtils.getImageFile( - context, - imagePath - ) - imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { - false - } - } - .toSet() - - // Log any missing images for debugging - val missingImages = referencedImagePaths - validImagePaths - if (missingImages.isNotEmpty()) { - android.util.Log.w( - "ClimbRepository", - "Some referenced images are missing: $missingImages" - ) - } - - return ZipExportImportUtils.createExportZip( - context = context, - exportData = exportData, - referencedImagePaths = validImagePaths, - directory = directory - ) - } catch (e: Exception) { - throw Exception("Export failed: ${e.message}") - } + suspend fun insertAttempt(attempt: Attempt) { + attemptDao.insertAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateAttempt(attempt: Attempt) { + attemptDao.updateAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteAttempt(attempt: Attempt) { + attemptDao.deleteAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() } suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { try { - // Collect all data with proper error handling + // Collect all data val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() @@ -138,14 +134,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - val exportData = - ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), version = "2.0", - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts + 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) } ) // Collect all referenced image paths and validate they exist @@ -160,7 +158,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) imagePath ) imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -169,7 +167,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ZipExportImportUtils.createExportZipToUri( context = context, uri = uri, - exportData = exportData, + exportData = backupData, referencedImagePaths = validImagePaths ) } catch (e: Exception) { @@ -195,7 +193,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Parse and validate the data structure val importData = try { - json.decodeFromString(importResult.jsonContent) + json.decodeFromString(importResult.jsonContent) } catch (e: Exception) { throw Exception("Invalid data format: ${e.message}") } @@ -209,52 +207,75 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Import gyms first (problems depend on gyms) - importData.gyms.forEach { gym -> + // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data + // state updates + importData.gyms.forEach { backupGym -> try { - gymDao.insertGym(gym) + gymDao.insertGym(backupGym.toGym()) } catch (e: Exception) { - throw Exception("Failed to import gym ${gym.name}: ${e.message}") + throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") } } // Import problems with updated image paths - val updatedProblems = + val updatedBackupProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) - updatedProblems.forEach { problem -> + // Import problems (depends on gyms) - use DAO directly + updatedBackupProblems.forEach { backupProblem -> try { - problemDao.insertProblem(problem) + problemDao.insertProblem(backupProblem.toProblem()) } catch (e: Exception) { - throw Exception("Failed to import problem ${problem.name}: ${e.message}") + throw Exception( + "Failed to import problem '${backupProblem.name}': ${e.message}" + ) } } - // Import sessions - importData.sessions.forEach { session -> + // Import sessions - use DAO directly + importData.sessions.forEach { backupSession -> try { - sessionDao.insertSession(session) + sessionDao.insertSession(backupSession.toClimbSession()) } catch (e: Exception) { - throw Exception("Failed to import session: ${e.message}") + throw Exception("Failed to import session '${backupSession.id}': ${e.message}") } } - // Import attempts last (depends on problems and sessions) - importData.attempts.forEach { attempt -> + // Import attempts last (depends on problems and sessions) - use DAO directly + importData.attempts.forEach { backupAttempt -> try { - attemptDao.insertAttempt(attempt) + attemptDao.insertAttempt(backupAttempt.toAttempt()) } catch (e: Exception) { - throw Exception("Failed to import attempt: ${e.message}") + throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") } } + + // 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}") } } + /** + * Sets the callback for auto-sync functionality. This should be called by the SyncService to + * register itself for auto-sync triggers. + */ + fun setAutoSyncCallback(callback: (() -> Unit)?) { + autoSyncCallback = callback + } + + /** + * Triggers auto-sync if enabled. This is called after any data modification to keep data + * synchronized across devices automatically. + */ + private fun triggerAutoSync() { + autoSyncCallback?.invoke() + } + private fun validateDataIntegrity( gyms: List, problems: List, @@ -291,7 +312,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - private fun validateImportData(importData: ClimbDataExport) { + private fun validateImportData(importData: ClimbDataBackup) { if (importData.gyms.isEmpty()) { throw Exception("Import data is invalid: no gyms found") } @@ -312,6 +333,10 @@ 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() @@ -320,11 +345,35 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // 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() + } + + suspend fun insertProblemWithoutSync(problem: Problem) { + problemDao.insertProblem(problem) + dataStateManager.updateDataState() + } + + suspend fun insertSessionWithoutSync(session: ClimbSession) { + sessionDao.insertSession(session) + dataStateManager.updateDataState() + } + + suspend fun insertAttemptWithoutSync(attempt: Attempt) { + attemptDao.insertAttempt(attempt) + dataStateManager.updateDataState() + } + private fun clearAllImages() { try { // Get the images directory @@ -339,13 +388,3 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } } - -@kotlinx.serialization.Serializable -data class ClimbDataExport( - val exportedAt: String, - val version: String = "2.0", - val gyms: List, - val problems: List, - val sessions: List, - val attempts: List -) 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 new file mode 100644 index 0000000..7fedf7f --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt @@ -0,0 +1,81 @@ +package com.atridad.openclimb.data.state + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.atridad.openclimb.utils.DateFormatUtils + +/** + * 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. + */ +class DataStateManager(context: Context) { + + companion object { + private const val TAG = "DataStateManager" + private const val PREFS_NAME = "openclimb_data_state" + private const val KEY_LAST_MODIFIED = "last_modified_timestamp" + private const val KEY_INITIALIZED = "state_initialized" + } + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + init { + // Initialize with current timestamp if this is the first time + if (!isInitialized()) { + updateDataState() + markAsInitialized() + Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}") + } + } + + /** + * Updates the data state timestamp to the current time. Call this whenever any data is modified + * (create, update, delete). + */ + fun updateDataState() { + val now = DateFormatUtils.nowISO8601() + prefs.edit().putString(KEY_LAST_MODIFIED, now).apply() + Log.d(TAG, "Data state updated to: $now") + } + + /** + * Gets the current data state timestamp. This represents when any data was last modified + * locally. + */ + fun getLastModified(): String { + return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601()) + ?: DateFormatUtils.nowISO8601() + } + + /** + * Sets the data state timestamp to a specific value. Used when importing data from server to + * sync the state. + */ + fun setLastModified(timestamp: String) { + prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply() + Log.d(TAG, "Data state set to: $timestamp") + } + + /** Resets the data state (for testing or complete data wipe). */ + fun reset() { + prefs.edit().clear().apply() + Log.d(TAG, "Data state reset") + } + + /** Checks if the data state has been initialized. */ + private fun isInitialized(): Boolean { + return prefs.getBoolean(KEY_INITIALIZED, false) + } + + /** Marks the data state as initialized. */ + private fun markAsInitialized() { + prefs.edit().putBoolean(KEY_INITIALIZED, true).apply() + } + + /** Gets debug information about the current state. */ + fun getDebugInfo(): String { + return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})" + } +} 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 new file mode 100644 index 0000000..9d193e6 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -0,0 +1,998 @@ +package com.atridad.openclimb.data.sync + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.atridad.openclimb.data.format.BackupAttempt +import com.atridad.openclimb.data.format.BackupClimbSession +import com.atridad.openclimb.data.format.BackupGym +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.migration.ImageMigrationService +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.state.DataStateManager +import com.atridad.openclimb.utils.DateFormatUtils +import com.atridad.openclimb.utils.ImageNamingUtils +import com.atridad.openclimb.utils.ImageUtils +import java.io.IOException +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class SyncService(private val context: Context, private val repository: ClimbRepository) { + + private val migrationService = ImageMigrationService(context, repository) + private val dataStateManager = DataStateManager(context) + private val syncMutex = Mutex() + + companion object { + private const val TAG = "SyncService" + } + + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) + + private val httpClient = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + coerceInputValues = true + } + + // State flows + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private val _lastSyncTime = MutableStateFlow(null) + val lastSyncTime: StateFlow = _lastSyncTime.asStateFlow() + + private val _syncError = MutableStateFlow(null) + val syncError: StateFlow = _syncError.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _isTesting = MutableStateFlow(false) + val isTesting: StateFlow = _isTesting.asStateFlow() + + // Configuration keys + private object Keys { + const val SERVER_URL = "sync_server_url" + const val AUTH_TOKEN = "sync_auth_token" + const val LAST_SYNC_TIME = "last_sync_time" + const val IS_CONNECTED = "sync_is_connected" + const val AUTO_SYNC_ENABLED = "auto_sync_enabled" + } + + // Configuration properties + var serverURL: String + get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" + set(value) { + sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply() + } + + var authToken: String + get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" + set(value) { + sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply() + } + + val isConfigured: Boolean + get() = serverURL.isNotEmpty() && authToken.isNotEmpty() + + var isAutoSyncEnabled: Boolean + get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) + set(value) { + sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply() + } + + 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(kotlinx.coroutines.Dispatchers.IO).launch { + triggerAutoSync() + } + } + } + + suspend fun downloadData(): ClimbDataBackup = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + val request = + Request.Builder() + .url("$serverURL/sync") + .get() + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Accept", "application/json") + .build() + + try { + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> { + val responseBody = + response.body?.string() + ?: throw SyncException.InvalidResponse( + "Empty response body" + ) + Log.d(TAG, "Downloaded data from server: ${responseBody.take(500)}...") + try { + val backup = json.decodeFromString(responseBody) + Log.d( + TAG, + "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) { + Log.d( + TAG, + "Server problem '${problem.name}' has images: ${problem.imagePaths}" + ) + } + } + + backup + } catch (e: Exception) { + Log.e(TAG, "Failed to decode download response: ${e.message}") + throw SyncException.DecodingError( + e.message ?: "Failed to decode response" + ) + } + } + 401 -> throw SyncException.Unauthorized + else -> throw SyncException.ServerError(response.code) + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun uploadData(backup: ClimbDataBackup): ClimbDataBackup = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + val jsonBody = json.encodeToString(backup) + Log.d(TAG, "Uploading JSON to server: $jsonBody") + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) + + val request = + Request.Builder() + .url("$serverURL/sync") + .put(requestBody) + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Content-Type", "application/json") + .build() + + try { + val response = httpClient.newCall(request).execute() + Log.d(TAG, "Upload response code: ${response.code}") + + when (response.code) { + 200 -> { + val responseBody = + response.body?.string() + ?: throw SyncException.InvalidResponse( + "Empty response body" + ) + try { + json.decodeFromString(responseBody) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode upload response: ${e.message}") + throw SyncException.DecodingError( + e.message ?: "Failed to decode response" + ) + } + } + 401 -> throw SyncException.Unauthorized + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e(TAG, "Server error ${response.code}: $errorBody") + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun uploadImage(filename: String, imageData: ByteArray) = + withContext(Dispatchers.IO) { + if (!isConfigured) { + 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()) + + val request = + Request.Builder() + .url("$serverURL/images/upload?filename=$justFilename") + .post(requestBody) + .addHeader("Authorization", "Bearer $authToken") + .build() + + try { + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> Unit // Success + 401 -> throw SyncException.Unauthorized + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e(TAG, "Image upload error ${response.code}: $errorBody") + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun downloadImage(filename: String): ByteArray = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + Log.d(TAG, "Downloading image from server: $filename") + val request = + Request.Builder() + .url("$serverURL/images/download?filename=$filename") + .get() + .addHeader("Authorization", "Bearer $authToken") + .build() + + try { + val response = httpClient.newCall(request).execute() + Log.d(TAG, "Image download response for $filename: ${response.code}") + + when (response.code) { + 200 -> { + val imageBytes = + response.body?.bytes() + ?: throw SyncException.InvalidResponse( + "Empty image response" + ) + Log.d( + TAG, + "Successfully downloaded image $filename: ${imageBytes.size} bytes" + ) + imageBytes + } + 401 -> throw SyncException.Unauthorized + 404 -> { + Log.w(TAG, "Image not found on server: $filename") + throw SyncException.ImageNotFound(filename) + } + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e( + TAG, + "Image download error ${response.code} for $filename: $errorBody" + ) + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + Log.e(TAG, "Network error downloading image $filename: ${e.message}") + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun syncWithServer() { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + if (!_isConnected.value) { + 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() || + localBackup.sessions.isNotEmpty() || + localBackup.attempts.isNotEmpty() + + val hasServerData = + serverBackup.gyms.isNotEmpty() || + serverBackup.problems.isNotEmpty() || + serverBackup.sessions.isNotEmpty() || + serverBackup.attempts.isNotEmpty() + + 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) + + Log.d( + TAG, + "Comparing timestamps: local=$localTimestamp, server=$serverTimestamp" + ) + + 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") + } + } + else -> { + Log.d(TAG, "No data to sync") + } + } + + // Update last sync time + val now = DateFormatUtils.nowISO8601() + _lastSyncTime.value = now + sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() + } catch (e: Exception) { + _syncError.value = e.message + throw e + } finally { + _isSyncing.value = false + } + } + } + + private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map { + val imagePathMapping = mutableMapOf() + + Log.d(TAG, "Starting to download images from server") + var totalImages = 0 + var downloadedImages = 0 + var failedImages = 0 + + for (problem in backup.problems) { + val imageCount = problem.imagePaths?.size ?: 0 + if (imageCount > 0) { + Log.d( + TAG, + "Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}" + ) + totalImages += imageCount + } + + problem.imagePaths?.forEachIndexed { index, imagePath -> + try { + 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) + } + + val localImagePath = + ImageUtils.saveImageFromBytesWithFilename( + context, + imageData, + consistentFilename + ) + + if (localImagePath != null) { + // Map original server filename to the full local relative path + imagePathMapping[serverFilename] = localImagePath + downloadedImages++ + Log.d( + TAG, + "Downloaded and mapped image: $serverFilename -> $localImagePath" + ) + } else { + Log.w(TAG, "Failed to save downloaded image locally: $imagePath") + failedImages++ + } + } catch (e: Exception) { + Log.w(TAG, "Failed to download image $imagePath: ${e.message}") + failedImages++ + } + } + } + + Log.d( + TAG, + "Image download completed: $downloadedImages downloaded, $failedImages failed, $totalImages total" + ) + return imagePathMapping + } + + private suspend fun syncImagesToServer() { + val allProblems = repository.getAllProblems().first() + val backup = + ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + version = "2.0", + formatVersion = "2.0", + gyms = emptyList(), + problems = allProblems.map { BackupProblem.fromProblem(it) }, + sessions = emptyList(), + attempts = emptyList() + ) + syncImagesForBackup(backup) + } + + private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { + Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") + + var totalImages = 0 + var uploadedImages = 0 + var failedImages = 0 + + for (problem in backup.problems) { + val imageCount = problem.imagePaths?.size ?: 0 + totalImages += imageCount + + Log.d(TAG, "Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}") + + problem.imagePaths?.forEachIndexed { index, imagePath -> + try { + val imageFile = ImageUtils.getImageFile(context, imagePath) + Log.d(TAG, "Checking image file: $imagePath -> ${imageFile.absolutePath}") + Log.d( + TAG, + "Image file exists: ${imageFile.exists()}, size: ${if (imageFile.exists()) imageFile.length() else 0} bytes" + ) + + if (imageFile.exists() && imageFile.length() > 0) { + 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, + index + ) + val newFile = java.io.File(imageFile.parent, newFilename) + if (imageFile.renameTo(newFile)) { + Log.d( + TAG, + "Renamed local image file: $filename -> $newFilename" + ) + // Update the problem's image path in memory for next sync + newFilename + } else { + Log.w( + TAG, + "Failed to rename local image file, using original" + ) + filename + } + } + + Log.d(TAG, "Uploading image: $consistentFilename (${imageData.size} bytes)") + uploadImage(consistentFilename, imageData) + uploadedImages++ + Log.d(TAG, "Successfully uploaded image: $consistentFilename") + } else { + Log.w( + TAG, + "Image file not found or empty: $imagePath at ${imageFile.absolutePath}" + ) + failedImages++ + } + } catch (e: Exception) { + Log.e(TAG, "Failed to upload image $imagePath: ${e.message}", e) + failedImages++ + } + } + } + + Log.d( + TAG, + "Image sync completed: $uploadedImages uploaded, $failedImages failed, $totalImages total" + ) + } + + private suspend fun createBackupFromRepository(): ClimbDataBackup { + val allGyms = repository.getAllGyms().first() + val allProblems = repository.getAllProblems().first() + val allSessions = repository.getAllSessions().first() + val allAttempts = repository.getAllAttempts().first() + + return ClimbDataBackup( + exportedAt = dataStateManager.getLastModified(), + 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) } + ) + } + + private suspend fun importBackupToRepository( + 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() + Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") + 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 + } + } + + // Import problems with updated image paths + backup.problems.forEach { backupProblem -> + try { + val updatedProblem = + if (imagePathMapping.isNotEmpty()) { + val newImagePaths = + backupProblem.imagePaths?.mapNotNull { 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 + ) + ) { + "problem_images/$filename" + } else { + // Generate consistent filename as fallback with + // full path + val index = + backupProblem.imagePaths.indexOf( + oldPath + ) + val consistentFilename = + ImageNamingUtils.generateImageFilename( + backupProblem.id, + index + ) + "problem_images/$consistentFilename" + } + } + ?: emptyList() + backupProblem.withUpdatedImagePaths(newImagePaths) + } else { + backupProblem + } + repository.insertProblemWithoutSync(updatedProblem.toProblem()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}") + } + } + + // Import sessions + backup.sessions.forEach { backupSession -> + try { + repository.insertSessionWithoutSync(backupSession.toClimbSession()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}") + } + } + + // Import attempts last + backup.attempts.forEach { backupAttempt -> + try { + repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}") + } + } + + // Update local data state to match imported data timestamp + dataStateManager.setLastModified(backup.exportedAt) + Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") + } + + /** Parses ISO8601 timestamp to milliseconds for comparison */ + private fun parseISO8601ToMillis(timestamp: String): Long { + return try { + Instant.parse(timestamp).toEpochMilli() + } catch (e: Exception) { + Log.w(TAG, "Failed to parse timestamp: $timestamp, using 0", e) + 0L + } + } + + /** Converts milliseconds to ISO8601 timestamp */ + private fun millisToISO8601(millis: Long): String { + return DateFormatUtils.millisToISO8601(millis) + } + + /** + * Fixes existing image paths in the database to include the proper directory structure. This + * corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg" + */ + suspend fun fixImagePaths(): Boolean { + return try { + Log.d(TAG, "Fixing existing image paths in database") + + val allProblems = repository.getAllProblems().first() + var fixedCount = 0 + + for (problem in allProblems) { + if (problem.imagePaths.isNotEmpty()) { + val originalPaths = problem.imagePaths + 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++ + fixedPath + } else { + path + } + } + + if (originalPaths != fixedPaths) { + val updatedProblem = problem.copy(imagePaths = fixedPaths) + repository.insertProblem(updatedProblem) + } + } + } + + Log.i(TAG, "Fixed $fixedCount image paths in database") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to fix image paths: ${e.message}", e) + false + } + } + + /** + * Performs image migration to ensure all images use consistent naming convention before sync + * operations. This should be called before any sync to avoid filename conflicts. + */ + suspend fun migrateImagesForSync(): Boolean { + return try { + Log.d(TAG, "Starting image migration for sync compatibility") + val result = migrationService.performFullMigration() + + when (result) { + is com.atridad.openclimb.data.migration.ImageMigrationResult.AlreadyCompleted -> { + Log.d(TAG, "Image migration already completed") + true + } + is com.atridad.openclimb.data.migration.ImageMigrationResult.Success -> { + Log.i( + TAG, + "Image migration completed: ${result.totalMigrated} images migrated, ${result.errors} errors" + ) + true + } + is com.atridad.openclimb.data.migration.ImageMigrationResult.Failed -> { + Log.e(TAG, "Image migration failed: ${result.error}") + false + } + } + } catch (e: Exception) { + Log.e(TAG, "Image migration error: ${e.message}", e) + false + } + } + + suspend fun testConnection() { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + _isTesting.value = true + _syncError.value = null + + try { + withContext(Dispatchers.IO) { + val request = + Request.Builder() + .url("$serverURL/sync") + .get() + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Accept", "application/json") + .build() + + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> { + _isConnected.value = true + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, true).apply() + } + 401 -> throw SyncException.Unauthorized + else -> throw SyncException.ServerError(response.code) + } + } + } catch (e: Exception) { + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() + _syncError.value = e.message + throw e + } finally { + _isTesting.value = false + } + } + + suspend fun triggerAutoSync() { + if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) { + 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 + } + + try { + syncWithServer() + } catch (e: Exception) { + Log.e(TAG, "Auto-sync failed: ${e.message}") + _syncError.value = e.message + } + } + + // DEPRECATED: Complex merge logic replaced with simple timestamp-based sync + // These methods are no longer used but kept for reference + @Deprecated("Use simple timestamp-based sync instead") + private fun performIntelligentMerge( + local: ClimbDataBackup, + server: ClimbDataBackup + ): ClimbDataBackup { + Log.d(TAG, "Merging data - preserving all entities to prevent data loss") + + val mergedGyms = mergeGyms(local.gyms, server.gyms) + val mergedProblems = mergeProblems(local.problems, server.problems) + val mergedSessions = mergeSessions(local.sessions, server.sessions) + val mergedAttempts = mergeAttempts(local.attempts, server.attempts) + + Log.d( + TAG, + "Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " + + "sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}" + ) + + return ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + version = "2.0", + formatVersion = "2.0", + gyms = mergedGyms, + problems = mergedProblems, + sessions = mergedSessions, + attempts = mergedAttempts + ) + } + + private fun mergeGyms(local: List, server: List): List { + val merged = mutableMapOf() + + // Add all local gyms + local.forEach { gym -> merged[gym.id] = gym } + + // Add server gyms, preferring newer updates + server.forEach { serverGym -> + val localGym = merged[serverGym.id] + if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) { + merged[serverGym.id] = serverGym + } + } + + return merged.values.toList() + } + + private fun mergeProblems( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local problems + local.forEach { problem -> merged[problem.id] = problem } + + // Add server problems, preferring newer updates + server.forEach { serverProblem -> + val localProblem = merged[serverProblem.id] + if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ) { + // Merge image paths to preserve all images + val allImagePaths = mutableSetOf() + localProblem?.imagePaths?.let { allImagePaths.addAll(it) } + serverProblem.imagePaths?.let { allImagePaths.addAll(it) } + + merged[serverProblem.id] = + serverProblem.withUpdatedImagePaths(allImagePaths.toList()) + } + } + + return merged.values.toList() + } + + private fun mergeSessions( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local sessions + local.forEach { session -> merged[session.id] = session } + + // Add server sessions, preferring newer updates + server.forEach { serverSession -> + val localSession = merged[serverSession.id] + if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt) + ) { + merged[serverSession.id] = serverSession + } + } + + return merged.values.toList() + } + + private fun mergeAttempts( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local attempts + local.forEach { attempt -> merged[attempt.id] = attempt } + + // Add server attempts, preferring newer updates + server.forEach { serverAttempt -> + val localAttempt = merged[serverAttempt.id] + if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) + ) { + merged[serverAttempt.id] = serverAttempt + } + } + + return merged.values.toList() + } + + private fun isNewerThan(dateString1: String, dateString2: String): Boolean { + return try { + // Try parsing as instant first + val date1 = Instant.parse(dateString1) + val date2 = Instant.parse(dateString2) + date1.isAfter(date2) + } catch (e: Exception) { + // Fallback to string comparison + dateString1 > dateString2 + } + } + + fun disconnect() { + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() + _syncError.value = null + } + + fun clearConfiguration() { + serverURL = "" + authToken = "" + isAutoSyncEnabled = true + _lastSyncTime.value = null + _isConnected.value = false + _syncError.value = null + + sharedPreferences.edit().clear().apply() + } +} + +// Removed SyncTrigger enum - now using simple auto sync on any data change + +sealed class SyncException(message: String) : Exception(message) { + object NotConfigured : + SyncException("Sync is not configured. Please set server URL and auth token.") + object NotConnected : SyncException("Not connected to server. Please test connection first.") + object Unauthorized : SyncException("Unauthorized. Please check your auth token.") + object InvalidURL : SyncException("Invalid server URL.") + data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") + data class InvalidResponse(val details: String) : + SyncException("Invalid server response: $details") + data class DecodingError(val details: String) : + SyncException("Failed to decode server response: $details") + data class ImageNotFound(val filename: String) : SyncException("Image not found: $filename") + data class NetworkError(val details: String) : SyncException("Network error: $details") +} 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 83ec388..a8bbca2 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 @@ -11,7 +11,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -20,6 +19,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService import com.atridad.openclimb.navigation.Screen import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.ui.components.NotificationPermissionDialog @@ -43,7 +43,9 @@ fun OpenClimbApp( val database = remember { OpenClimbDatabase.getDatabase(context) } val repository = remember { ClimbRepository(database, context) } - val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository)) + val syncService = remember { SyncService(context, repository) } + val viewModel: ClimbViewModel = + viewModel(factory = ClimbViewModelFactory(repository, syncService)) // Notification permission state var showNotificationPermissionDialog by remember { mutableStateOf(false) } @@ -73,6 +75,9 @@ 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() diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index f87eb53..d5ea537 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -18,412 +18,811 @@ import androidx.compose.ui.unit.dp import com.atridad.openclimb.R import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import java.io.File +import java.time.Instant +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen( - viewModel: ClimbViewModel -) { +fun SettingsScreen(viewModel: ClimbViewModel) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current - - // State for reset confirmation dialog + val coroutineScope = rememberCoroutineScope() + + // Sync service state + val syncService = viewModel.syncService + val isSyncing by syncService.isSyncing.collectAsState() + val isConnected by syncService.isConnected.collectAsState() + val isTesting by syncService.isTesting.collectAsState() + val lastSyncTime by syncService.lastSyncTime.collectAsState() + val syncError by syncService.syncError.collectAsState() + + // State for dialogs var showResetDialog by remember { mutableStateOf(false) } - - val packageInfo = remember { - context.packageManager.getPackageInfo(context.packageName, 0) - } + var showSyncConfigDialog by remember { mutableStateOf(false) } + var showDisconnectDialog by remember { mutableStateOf(false) } + + // Sync configuration state + var serverUrl by remember { mutableStateOf(syncService.serverURL) } + var authToken by remember { mutableStateOf(syncService.authToken) } + + val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val appVersion = packageInfo.versionName - + // File picker launcher for import - only accepts ZIP files - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri -> - uri?.let { - try { - val inputStream = context.contentResolver.openInputStream(uri) - // Determine file extension from content resolver - val fileName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - cursor.getString(nameIndex) - } else null - } ?: "import_file" - - // Only allow ZIP files - if (!fileName.lowercase().endsWith(".zip")) { - viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.") - return@let - } - - val tempFile = File(context.cacheDir, "temp_import.zip") - - inputStream?.use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) + val importLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri + -> + uri?.let { + try { + val inputStream = context.contentResolver.openInputStream(uri) + // Determine file extension from content resolver + val fileName = + context.contentResolver.query(uri, null, null, null, null)?.use { + cursor -> + val nameIndex = + cursor.getColumnIndex( + android.provider.OpenableColumns.DISPLAY_NAME + ) + if (nameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else null + } + ?: "import_file" + + // Only allow ZIP files + if (!fileName.lowercase().endsWith(".zip")) { + viewModel.setError( + "Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb." + ) + return@let + } + + val tempFile = File(context.cacheDir, "temp_import.zip") + + inputStream?.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + viewModel.importData(tempFile) + } catch (e: Exception) { + viewModel.setError("Failed to read file: ${e.message}") } } - viewModel.importData(tempFile) - } catch (e: Exception) { - viewModel.setError("Failed to read file: ${e.message}") } - } - } - + // File picker launcher for export - ZIP format with images - val exportZipLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/zip") - ) { uri -> - uri?.let { - try { - viewModel.exportDataToZipUri(context, uri) - } catch (e: Exception) { - viewModel.setError("Failed to save file: ${e.message}") + val exportZipLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + try { + viewModel.exportDataToZipUri(context, uri) + } catch (e: Exception) { + viewModel.setError("Failed to save file: ${e.message}") + } + } } - } - } - + LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_mountains), - contentDescription = "OpenClimb Logo", - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + painter = painterResource(id = R.drawable.ic_mountains), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary ) Text( - text = "Settings", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) } } - + // Data Management Section item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "Data Management", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Data Management", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(12.dp)) - - // Export Data + + // Export Data Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Export Data with Images") }, - supportingContent = { Text("Export all your climbing data and images to ZIP file (recommended)") }, - leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, - trailingContent = { - TextButton( - onClick = { - val defaultFileName = "openclimb_export_${ + headlineContent = { Text("Export Data with Images") }, + supportingContent = { + Text( + "Export all your climbing data and images to ZIP file (recommended)" + ) + }, + leadingContent = { + Icon(Icons.Default.Share, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = + "openclimb_export_${ java.time.LocalDateTime.now() .toString() .replace(":", "-") .replace(".", "-") }.zip" - exportZipLauncher.launch(defaultFileName) - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Export ZIP") + exportZipLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export ZIP") + } } } - } ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Import Data") }, - supportingContent = { Text("Import climbing data from ZIP file (recommended format)") }, - leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, - trailingContent = { - TextButton( - onClick = { - importLauncher.launch("application/zip") - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Import") + headlineContent = { Text("Import Data") }, + supportingContent = { + Text("Import climbing data from ZIP file (recommended format)") + }, + leadingContent = { + Icon(Icons.Default.Add, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { importLauncher.launch("application/zip") }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Import") + } } } - } ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Reset All Data") }, - supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") }, - leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, - trailingContent = { - TextButton( - onClick = { - showResetDialog = true - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Reset", color = MaterialTheme.colorScheme.error) + headlineContent = { Text("Reset All Data") }, + supportingContent = { + Text( + "Permanently delete all gyms, problems, sessions, attempts, and images" + ) + }, + leadingContent = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + trailingContent = { + TextButton( + onClick = { showResetDialog = true }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Reset", color = MaterialTheme.colorScheme.error) + } } } - } ) } } } } - + + // Sync Section + item { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "Sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (syncService.isConfigured) { + // Connected state + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isConnected) + MaterialTheme.colorScheme + .primaryContainer.copy( + alpha = 0.3f + ) + else + MaterialTheme.colorScheme + .surfaceVariant.copy( + alpha = 0.3f + ) + ) + ) { + ListItem( + headlineContent = { + Text( + if (isConnected) "Connected to Server" + else "Server Configured" + ) + }, + supportingContent = { + Column { + Text("Server: ${syncService.serverURL}") + lastSyncTime?.let { time -> + Text( + "Last sync: ${ + try { + Instant.parse(time).toString() + } catch (e: Exception) { + time + } + }", + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + leadingContent = { + Icon( + if (isConnected) Icons.Default.CloudDone + else Icons.Default.Cloud, + contentDescription = null, + tint = + if (isConnected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme + .onSurfaceVariant + ) + }, + trailingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Manual Sync Button + TextButton( + onClick = { + coroutineScope.launch { + viewModel.performManualSync() + } + }, + enabled = isConnected && !isSyncing + ) { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Sync") + } + } + + // Configure Button + TextButton(onClick = { showSyncConfigDialog = true }) { + Text("Configure") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Auto-sync settings + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Sync Mode", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Auto-sync") + Text( + text = + "Sync automatically on app launch and data changes", + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.7f + ), + maxLines = 2 + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = syncService.isAutoSyncEnabled, + onCheckedChange = { syncService.isAutoSyncEnabled = it } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Disconnect option + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer + .copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Disconnect") }, + supportingContent = { Text("Clear sync configuration") }, + leadingContent = { + Icon( + Icons.Default.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + trailingContent = { + TextButton(onClick = { showDisconnectDialog = true }) { + Text( + "Disconnect", + color = MaterialTheme.colorScheme.error + ) + } + } + ) + } + } else { + // Not configured state + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Setup Sync") }, + supportingContent = { + Text("Connect to your OpenClimb sync server") + }, + leadingContent = { + Icon(Icons.Default.CloudSync, contentDescription = null) + }, + trailingContent = { + TextButton(onClick = { showSyncConfigDialog = true }) { + Text("Setup") + } + } + ) + } + } + + // Show sync error if any + syncError?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } + } + // App Information Section item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "App Information", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "App Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(12.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + 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 = "OpenClimb Logo", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text("OpenClimb") - } - }, - supportingContent = { Text("Track your climbing progress") }, - leadingContent = { } + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = + painterResource( + id = R.drawable.ic_mountains + ), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text("OpenClimb") + } + }, + supportingContent = { Text("Track your climbing progress") }, + leadingContent = {} ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Version") }, - supportingContent = { Text(appVersion ?: "Unknown") }, - leadingContent = { Icon(Icons.Default.Info, contentDescription = null) } + headlineContent = { Text("Version") }, + supportingContent = { Text(appVersion ?: "Unknown") }, + leadingContent = { + Icon(Icons.Default.Info, contentDescription = null) + } ) } } } } - - } - + // Show loading/message states if (uiState.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } - + uiState.message?.let { message -> LaunchedEffect(message) { kotlinx.coroutines.delay(5000) viewModel.clearMessage() } - + Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(12.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer ) } } } - + uiState.error?.let { error -> LaunchedEffect(error) { kotlinx.coroutines.delay(5000) viewModel.clearError() } - + Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer ) } } } - + // Reset confirmation dialog if (showResetDialog) { AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { Text("Reset All Data") }, - text = { - Column { - Text("Are you sure you want to reset all data?") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This will permanently delete:", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This action cannot be undone. Consider exporting your data first.", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error - ) - } - }, - confirmButton = { - TextButton( - onClick = { - viewModel.resetAllData() - showResetDialog = false + onDismissRequest = { showResetDialog = false }, + title = { Text("Reset All Data") }, + text = { + Column { + Text("Are you sure you want to reset all data?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will permanently delete:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "This action cannot be undone. Consider exporting your data first.", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) } - ) { - Text("Reset All Data", color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.resetAllData() + showResetDialog = false + } + ) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { - Text("Cancel") + ) + } + + // Sync Configuration Dialog + if (showSyncConfigDialog) { + AlertDialog( + onDismissRequest = { showSyncConfigDialog = false }, + title = { Text("Sync Configuration") }, + text = { + Column { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://your-server.com") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = authToken, + onValueChange = { authToken = it }, + label = { Text("Auth Token") }, + placeholder = { Text("your-secret-token") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (syncService.isConfigured) { + Text( + text = "Test connection before enabling sync features", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = "Enter your server URL and auth token to enable sync", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + Row { + if (syncService.isConfigured) { + TextButton( + onClick = { + coroutineScope.launch { + try { + // Save configuration first + syncService.serverURL = serverUrl.trim() + syncService.authToken = authToken.trim() + viewModel.testSyncConnection() + showSyncConfigDialog = false + } catch (e: Exception) { + // Error will be shown via syncError state + } + } + }, + enabled = + !isTesting && + serverUrl.isNotBlank() && + authToken.isNotBlank() + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Test Connection") + } + } + + Spacer(modifier = Modifier.width(8.dp)) + } + + TextButton( + onClick = { + syncService.serverURL = serverUrl.trim() + syncService.authToken = authToken.trim() + showSyncConfigDialog = false + }, + enabled = serverUrl.isNotBlank() && authToken.isNotBlank() + ) { Text("Save") } + } + }, + dismissButton = { + TextButton( + onClick = { + // Reset to current values + serverUrl = syncService.serverURL + authToken = syncService.authToken + showSyncConfigDialog = false + } + ) { Text("Cancel") } + } + ) + } + + // Disconnect Dialog + if (showDisconnectDialog) { + AlertDialog( + onDismissRequest = { showDisconnectDialog = false }, + title = { Text("Disconnect from Sync") }, + text = { + Text( + "Are you sure you want to disconnect from the sync server? This will clear your server configuration and disable auto-sync." + ) + }, + confirmButton = { + TextButton( + onClick = { + syncService.clearConfiguration() + serverUrl = "" + authToken = "" + showDisconnectDialog = false + } + ) { Text("Disconnect", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") } } - } ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 024f789..87c243f 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService import com.atridad.openclimb.service.SessionTrackingService import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.SessionShareUtils @@ -15,7 +16,8 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { +class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) : + ViewModel() { // UI State flows private val _uiState = MutableStateFlow(ClimbUiState()) @@ -112,6 +114,7 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { viewModelScope.launch { repository.insertProblem(problem) ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback } } @@ -265,6 +268,8 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback + _uiState.value = _uiState.value.copy(message = "Session completed!") } } @@ -290,6 +295,7 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { viewModelScope.launch { repository.insertAttempt(attempt) ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback } } @@ -383,6 +389,23 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { _uiState.value = _uiState.value.copy(error = null) } + // Sync-related methods + suspend fun performManualSync() { + try { + syncService.syncWithServer() + } catch (e: Exception) { + setError("Sync failed: ${e.message}") + } + } + + suspend fun testSyncConnection() { + try { + syncService.testConnection() + } catch (e: Exception) { + setError("Connection test failed: ${e.message}") + } + } + fun setError(message: String) { _uiState.value = _uiState.value.copy(error = message) } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt index 1fe048e..ad928b4 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt @@ -3,15 +3,17 @@ package com.atridad.openclimb.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService class ClimbViewModelFactory( - private val repository: ClimbRepository + private val repository: ClimbRepository, + private val syncService: SyncService ) : ViewModelProvider.Factory { - + @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) { - return ClimbViewModel(repository) as T + return ClimbViewModel(repository, syncService) as T } throw IllegalArgumentException("Unknown ViewModel class") } 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 new file mode 100644 index 0000000..d7efbb9 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt @@ -0,0 +1,68 @@ +package com.atridad.openclimb.utils + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +object DateFormatUtils { + + /** + * ISO 8601 formatter matching iOS date format exactly Produces dates like: + * "2025-09-07T22:00:40.014Z" + */ + 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" + */ + 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" + */ + 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 + */ + 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) { + null + } + } + } + + /** + * 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 + */ + 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" + */ + 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 new file mode 100644 index 0000000..94eac0f --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt @@ -0,0 +1,147 @@ +package com.atridad.openclimb.utils + +import java.security.MessageDigest +import java.util.* + +/** + * Utility for creating consistent image filenames across iOS and Android platforms. Uses + * deterministic naming based on problem ID and timestamp to ensure sync compatibility. + */ +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 + */ + fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { + // Create a deterministic hash from problemId + timestamp + index + val input = "${problemId}_${timestamp}_${imageIndex}" + val hash = createHash(input) + + 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 + */ + 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 + */ + fun extractProblemIdFromFilename(filename: String): String? { + if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { + return null + } + + // Format: problem_{hash}_{index}.jpg + val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) + val parts = nameWithoutExtension.split("_") + + if (parts.size != 3 || parts[0] != "problem") { + return null + } + + // We can't extract the original problem ID from the hash, + // but we can validate the format + 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 + */ + fun isValidImageFilename(filename: String): Boolean { + if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { + return false + } + + val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) + val parts = nameWithoutExtension.split("_") + + return parts.size == 3 && + parts[0] == "problem" && + parts[1].length == HASH_LENGTH && + 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 + */ + fun 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 a timestamp based on the old filename to maintain some consistency + val timestamp = DateFormatUtils.nowISO8601() + 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 + */ + private fun createHash(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + val hashHex = hashBytes.joinToString("") { "%02x".format(it) } + 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 + */ + fun batchRenameForProblem( + problemId: String, + existingFilenames: List + ): Map { + val renameMap = mutableMapOf() + + existingFilenames.forEachIndexed { index, oldFilename -> + val newFilename = migrateFilename(oldFilename, problemId, index) + if (newFilename != oldFilename) { + renameMap[oldFilename] = newFilename + } + } + + return renameMap + } +} 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 e3448ca..78fb3cd 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 @@ -5,20 +5,18 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import androidx.core.graphics.scale import java.io.File import java.io.FileOutputStream import java.util.UUID -import androidx.core.graphics.scale object ImageUtils { - + private const val IMAGES_DIR = "problem_images" 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()) { @@ -26,25 +24,39 @@ object ImageUtils { } return imagesDir } - + /** - * Saves an image from URI to app's private storage with compression + * 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 */ - fun saveImageFromUri(context: Context, imageUri: Uri): String? { + fun saveImageFromUri( + context: Context, + imageUri: Uri, + problemId: String? = null, + 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) - } ?: return null + val originalBitmap = + context.contentResolver.openInputStream(imageUri)?.use { input -> + BitmapFactory.decodeStream(input) + } + ?: return null val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val compressedBitmap = compressImage(orientedBitmap) - // Generate unique filename - val filename = "${UUID.randomUUID()}.jpg" + // Generate filename using naming convention if problem info provided + val filename = + if (problemId != null && imageIndex != null) { + ImageNamingUtils.generateImageFilename(problemId, imageIndex) + } else { + "${UUID.randomUUID()}.jpg" + } val imageFile = File(getImagesDirectory(context), filename) // Save compressed image @@ -66,20 +78,19 @@ object ImageUtils { null } } - - /** - * Corrects image orientation based on EXIF data - */ + + /** Corrects image orientation based on EXIF data */ private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap { return try { val inputStream = context.contentResolver.openInputStream(imageUri) inputStream?.use { input -> val exif = android.media.ExifInterface(input) - val orientation = exif.getAttributeInt( - android.media.ExifInterface.TAG_ORIENTATION, - android.media.ExifInterface.ORIENTATION_NORMAL - ) - + val orientation = + exif.getAttributeInt( + android.media.ExifInterface.TAG_ORIENTATION, + android.media.ExifInterface.ORIENTATION_NORMAL + ) + val matrix = android.graphics.Matrix() when (orientation) { android.media.ExifInterface.ORIENTATION_ROTATE_90 -> { @@ -106,36 +117,42 @@ object ImageUtils { matrix.postScale(-1f, 1f) } } - + if (matrix.isIdentity) { bitmap } else { android.graphics.Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true ) } - } ?: bitmap + } + ?: bitmap } catch (e: Exception) { e.printStackTrace() bitmap } } - - /** - * Compresses and resizes an image bitmap - */ + + /** Compresses and resizes an image bitmap */ @SuppressLint("UseKtx") private fun compressImage(original: Bitmap): Bitmap { val width = original.width val height = original.height - + // 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 - } - + 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 + } + return if (scaleFactor < 1f) { val newWidth = (width * scaleFactor).toInt() val newHeight = (height * scaleFactor).toInt() @@ -144,7 +161,7 @@ object ImageUtils { original } } - + /** * Gets the full file path for an image * @param context Android context @@ -152,9 +169,16 @@ object ImageUtils { * @return Full file path */ fun getImageFile(context: Context, relativePath: String): File { - return File(context.filesDir, relativePath) + // If relativePath already contains the directory, use it as-is + // Otherwise, assume it's just a filename and add the images directory + return if (relativePath.contains("/")) { + File(context.filesDir, relativePath) + } else { + // Just a filename - look in the images directory + File(getImagesDirectory(context), relativePath) + } } - + /** * Deletes an image file * @param context Android context @@ -180,12 +204,12 @@ object ImageUtils { fun importImageFile(context: Context, sourceFile: File): String? { return try { if (!sourceFile.exists()) return null - + // Generate new filename to avoid conflicts val extension = sourceFile.extension.ifEmpty { "jpg" } val filename = "${UUID.randomUUID()}.$extension" val destFile = File(getImagesDirectory(context), filename) - + sourceFile.copyTo(destFile, overwrite = true) "$IMAGES_DIR/$filename" } catch (e: Exception) { @@ -193,7 +217,7 @@ object ImageUtils { null } } - + /** * Gets all image files in the images directory * @param context Android context @@ -203,16 +227,148 @@ object ImageUtils { return try { val imagesDir = getImagesDirectory(context) imagesDir.listFiles()?.mapNotNull { file -> - if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) { + if (file.isFile && + (file.extension == "jpg" || + file.extension == "jpeg" || + file.extension == "png") + ) { "$IMAGES_DIR/${file.name}" } else null - } ?: emptyList() + } + ?: emptyList() } catch (e: Exception) { e.printStackTrace() emptyList() } } - + + /** + * 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 + */ + fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { + return try { + val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null + + val compressedBitmap = compressImage(bitmap) + + // Generate unique filename + val filename = "${UUID.randomUUID()}.jpg" + val imageFile = File(getImagesDirectory(context), filename) + + // Save compressed image + FileOutputStream(imageFile).use { output -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) + } + + // Clean up bitmaps + bitmap.recycle() + compressedBitmap.recycle() + + // Return relative path + "$IMAGES_DIR/$filename" + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * 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 + */ + fun saveImageFromBytesWithFilename( + context: Context, + imageData: ByteArray, + filename: String + ): String? { + return try { + val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null + + val compressedBitmap = compressImage(bitmap) + + // Use the provided filename instead of generating a new UUID + val imageFile = File(getImagesDirectory(context), filename) + + // Save compressed image + FileOutputStream(imageFile).use { output -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) + } + + // Clean up bitmaps + bitmap.recycle() + compressedBitmap.recycle() + + // Return relative path + "$IMAGES_DIR/$filename" + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * 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 + */ + fun migrateImageNaming( + context: Context, + problemId: String, + currentImagePaths: List + ): Map { + val migrationMap = mutableMapOf() + + currentImagePaths.forEachIndexed { index, oldPath -> + val oldFilename = oldPath.substringAfterLast('/') + val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index) + + if (oldFilename != newFilename) { + try { + val oldFile = getImageFile(context, oldPath) + val newFile = File(getImagesDirectory(context), newFilename) + + if (oldFile.exists() && oldFile.renameTo(newFile)) { + val newPath = "$IMAGES_DIR/$newFilename" + migrationMap[oldPath] = newPath + } + } catch (e: Exception) { + // Log error but continue with other images + e.printStackTrace() + } + } + } + + 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 + */ + fun batchMigrateAllImages( + context: Context, + problemImageMap: Map> + ): Map { + val allMigrations = mutableMapOf() + + problemImageMap.forEach { (problemId, imagePaths) -> + val migrations = migrateImageNaming(context, problemId, imagePaths) + allMigrations.putAll(migrations) + } + + return allMigrations + } + /** * Cleans up orphaned images that are not referenced by any problems * @param context Android context @@ -222,10 +378,8 @@ object ImageUtils { try { val allImages = getAllImages(context) val orphanedImages = allImages.filter { it !in referencedPaths } - - orphanedImages.forEach { path -> - deleteImage(context, path) - } + + orphanedImages.forEach { path -> deleteImage(context, path) } } catch (e: Exception) { e.printStackTrace() } 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 9bd349b..a8ee1a6 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 @@ -1,7 +1,8 @@ package com.atridad.openclimb.utils import android.content.Context -import kotlinx.serialization.json.Json +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -10,13 +11,15 @@ import java.time.LocalDateTime import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object ZipExportImportUtils { - + private const val DATA_JSON_FILENAME = "data.json" 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 @@ -26,19 +29,26 @@ object ZipExportImportUtils { * @return The created ZIP file */ fun createExportZip( - context: Context, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set, - directory: File? = null + context: Context, + exportData: ClimbDataBackup, + referencedImagePaths: Set, + directory: File? = null ): File { - val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb") + val exportDir = + directory + ?: File( + context.getExternalFilesDir( + android.os.Environment.DIRECTORY_DOCUMENTS + ), + "OpenClimb" + ) if (!exportDir.exists()) { exportDir.mkdirs() } - + val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") - + try { ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> // Add metadata file first @@ -47,19 +57,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -68,31 +78,39 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } zipOut.closeEntry() successfulImages++ } else { - android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath") + android.util.Log.w( + "ZipExportImportUtils", + "Image file not found or empty: $imagePath" + ) } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - + // Log export summary - android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included") + android.util.Log.i( + "ZipExportImportUtils", + "Export completed: ${successfulImages}/${referencedImagePaths.size} images included" + ) } - + // Validate the created ZIP file if (!zipFile.exists() || zipFile.length() == 0L) { throw IOException("Failed to create ZIP file: file is empty or doesn't exist") } - + return zipFile - } catch (e: Exception) { // Clean up failed export if (zipFile.exists()) { @@ -101,7 +119,7 @@ object ZipExportImportUtils { throw IOException("Failed to create export ZIP: ${e.message}") } } - + /** * Creates a ZIP file and writes it to a provided URI * @param context Android context @@ -110,10 +128,10 @@ object ZipExportImportUtils { * @param referencedImagePaths Set of image paths referenced in the data */ fun createExportZipToUri( - context: Context, - uri: android.net.Uri, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + context: Context, + uri: android.net.Uri, + exportData: ClimbDataBackup, + referencedImagePaths: Set ) { try { context.contentResolver.openOutputStream(uri)?.use { outputStream -> @@ -124,19 +142,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -145,7 +163,7 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } @@ -153,22 +171,28 @@ object ZipExportImportUtils { successfulImages++ } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - - android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included") + + android.util.Log.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: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + exportData: ClimbDataBackup, + referencedImagePaths: Set ): String { return buildString { appendLine("OpenClimb Export Metadata") @@ -183,15 +207,13 @@ object ZipExportImportUtils { appendLine("Format: ZIP with embedded JSON data and images") } } - - /** - * Data class to hold extraction results - */ + + /** Data class to hold extraction results */ data class ImportResult( - val jsonContent: String, - val importedImagePaths: Map // original filename -> new relative path + val jsonContent: String, + 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 @@ -200,106 +222,125 @@ object ZipExportImportUtils { */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" - var metadataContent = "" val importedImagePaths = mutableMapOf() var foundRequiredFiles = mutableSetOf() - + try { ZipInputStream(FileInputStream(zipFile)).use { zipIn -> var entry = zipIn.nextEntry - + while (entry != null) { when { entry.name == METADATA_FILENAME -> { // Read metadata for validation - metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) + val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("metadata") - android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}") + android.util.Log.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/") - + try { // Create temporary file to hold the extracted image - val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir) - - FileOutputStream(tempFile).use { output -> - zipIn.copyTo(output) - } - + val tempFile = + File.createTempFile( + "import_image_", + "_$originalFilename", + context.cacheDir + ) + + FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } + // Validate the extracted image if (tempFile.exists() && tempFile.length() > 0) { // Import the image to permanent storage val newPath = ImageUtils.importImageFile(context, tempFile) if (newPath != null) { importedImagePaths[originalFilename] = newPath - android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath") + android.util.Log.d( + "ZipExportImportUtils", + "Successfully imported image: $originalFilename -> $newPath" + ) } else { - android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename") + android.util.Log.w( + "ZipExportImportUtils", + "Failed to import image: $originalFilename" + ) } } else { - android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename") + android.util.Log.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}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to process image $originalFilename: ${e.message}" + ) } } - else -> { - android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}") + android.util.Log.d( + "ZipExportImportUtils", + "Skipping ZIP entry: ${entry.name}" + ) } } - + zipIn.closeEntry() entry = zipIn.nextEntry } } - + // Validate that we found the required files if (!foundRequiredFiles.contains("data")) { throw IOException("Invalid ZIP file: data.json not found") } - + if (jsonContent.isBlank()) { throw IOException("Invalid ZIP file: data.json is empty") } - - android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed") - + + android.util.Log.i( + "ZipExportImportUtils", + "Import extraction completed: ${importedImagePaths.size} images processed" + ) + return ImportResult(jsonContent, importedImagePaths) - } catch (e: Exception) { throw IOException("Failed to extract import ZIP: ${e.message}") } } /** - * Updates image paths in a problem list after import - * This function maps the old image paths to the new ones after import + * Updates image paths in a problem list after import This function maps the old image paths to + * the new ones after import */ fun updateProblemImagePaths( - problems: List, - imagePathMapping: Map - ): List { + problems: List, + imagePathMapping: Map + ): List { return problems.map { problem -> - val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath -> - // Extract filename from the old path - val filename = oldPath.substringAfterLast("/") - imagePathMapping[filename] - } - problem.copy(imagePaths = updatedImagePaths) + val updatedImagePaths = + (problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> + // Extract filename from the old path + val filename = oldPath.substringAfterLast("/") + imagePathMapping[filename] + } + problem.withUpdatedImagePaths(updatedImagePaths) } } } diff --git a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt new file mode 100644 index 0000000..fd687c9 --- /dev/null +++ b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt @@ -0,0 +1,451 @@ +package com.atridad.openclimb + +import com.atridad.openclimb.data.format.* +import com.atridad.openclimb.data.model.* +import org.junit.Assert.* +import org.junit.Test + +class SyncMergeLogicTest { + + @Test + fun `test intelligent merge preserves all data`() { + // Create local data + val localGyms = + listOf( + BackupGym( + id = "gym1", + name = "Local Gym 1", + location = "Local Location", + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = listOf(DifficultySystem.V_SCALE), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localProblems = + listOf( + BackupProblem( + id = "problem1", + gymId = "gym1", + name = "Local Problem", + description = "Local description", + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"), + tags = listOf("local"), + location = null, + imagePaths = listOf("local_image.jpg"), + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localSessions = + listOf( + BackupClimbSession( + id = "session1", + gymId = "gym1", + date = "2024-01-01", + startTime = "2024-01-01T10:00:00", + endTime = "2024-01-01T12:00:00", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localAttempts = + listOf( + BackupAttempt( + id = "attempt1", + sessionId = "session1", + problemId = "problem1", + result = AttemptResult.COMPLETED, + highestHold = null, + notes = null, + duration = 300, + restTime = null, + timestamp = "2024-01-01T10:30:00", + createdAt = "2024-01-01T10:30:00" + ) + ) + + val localBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = localGyms, + problems = localProblems, + sessions = localSessions, + attempts = localAttempts + ) + + // Create server data with some overlapping and some unique data + val serverGyms = + listOf( + // Same gym but with newer update + BackupGym( + id = "gym1", + name = "Updated Gym 1", + location = "Updated Location", + supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT), + difficultySystems = + listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = "Updated notes", + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T12:00:00" // Newer update + ), + // Unique server gym + BackupGym( + id = "gym2", + name = "Server Gym 2", + location = "Server Location", + supportedClimbTypes = listOf(ClimbType.TRAD), + difficultySystems = listOf(DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T11:00:00", + updatedAt = "2024-01-01T11:00:00" + ) + ) + + val serverProblems = + listOf( + // Same problem but with newer update and different images + BackupProblem( + id = "problem1", + gymId = "gym1", + name = "Updated Problem", + description = "Updated description", + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"), + tags = listOf("updated", "server"), + location = "Updated location", + imagePaths = listOf("server_image.jpg"), + isActive = true, + dateSet = "2024-01-01", + notes = "Updated notes", + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T11:00:00" // Newer update + ), + // Unique server problem + BackupProblem( + id = "problem2", + gymId = "gym2", + name = "Server Problem", + description = "Server description", + climbType = ClimbType.TRAD, + difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"), + tags = listOf("server"), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T11:00:00", + updatedAt = "2024-01-01T11:00:00" + ) + ) + + val serverSessions = + listOf( + // Unique server session + BackupClimbSession( + id = "session2", + gymId = "gym2", + date = "2024-01-02", + startTime = "2024-01-02T14:00:00", + endTime = "2024-01-02T16:00:00", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = "Server session", + createdAt = "2024-01-02T14:00:00", + updatedAt = "2024-01-02T14:00:00" + ) + ) + + val serverAttempts = + listOf( + // Unique server attempt + BackupAttempt( + id = "attempt2", + sessionId = "session2", + problemId = "problem2", + result = AttemptResult.FELL, + highestHold = "Last move", + notes = "Almost had it", + duration = 180, + restTime = 60, + timestamp = "2024-01-02T14:30:00", + createdAt = "2024-01-02T14:30:00" + ) + ) + + val serverBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T12:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = serverGyms, + problems = serverProblems, + sessions = serverSessions, + attempts = serverAttempts + ) + + // Simulate merge logic + val mergedBackup = performIntelligentMerge(localBackup, serverBackup) + + // Verify merge results + assertEquals("Should have 2 gyms (1 updated, 1 new)", 2, mergedBackup.gyms.size) + assertEquals("Should have 2 problems (1 updated, 1 new)", 2, mergedBackup.problems.size) + assertEquals("Should have 2 sessions (1 local, 1 server)", 2, mergedBackup.sessions.size) + assertEquals("Should have 2 attempts (1 local, 1 server)", 2, mergedBackup.attempts.size) + + // Verify gym merge - server version should win (newer update) + val mergedGym1 = mergedBackup.gyms.find { it.id == "gym1" }!! + assertEquals("Updated Gym 1", mergedGym1.name) + assertEquals("Updated Location", mergedGym1.location) + assertEquals("Updated notes", mergedGym1.notes) + assertEquals("2024-01-01T12:00:00", mergedGym1.updatedAt) + + // Verify unique server gym is preserved + val mergedGym2 = mergedBackup.gyms.find { it.id == "gym2" }!! + assertEquals("Server Gym 2", mergedGym2.name) + + // Verify problem merge - server version should win but images should be merged + val mergedProblem1 = mergedBackup.problems.find { it.id == "problem1" }!! + assertEquals("Updated Problem", mergedProblem1.name) + assertEquals("Updated description", mergedProblem1.description) + assertEquals("2024-01-01T11:00:00", mergedProblem1.updatedAt) + + // Images should be merged (both local and server images preserved) + assertTrue( + "Should contain local image", + mergedProblem1.imagePaths!!.contains("local_image.jpg") + ) + assertTrue( + "Should contain server image", + mergedProblem1.imagePaths!!.contains("server_image.jpg") + ) + assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size) + + // Verify unique server problem is preserved + val mergedProblem2 = mergedBackup.problems.find { it.id == "problem2" }!! + assertEquals("Server Problem", mergedProblem2.name) + + // Verify all sessions are preserved + assertTrue( + "Should contain local session", + mergedBackup.sessions.any { it.id == "session1" } + ) + assertTrue( + "Should contain server session", + mergedBackup.sessions.any { it.id == "session2" } + ) + + // Verify all attempts are preserved + assertTrue( + "Should contain local attempt", + mergedBackup.attempts.any { it.id == "attempt1" } + ) + assertTrue( + "Should contain server attempt", + mergedBackup.attempts.any { it.id == "attempt2" } + ) + } + + @Test + fun `test date comparison logic`() { + assertTrue( + "ISO instant should be newer", + isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z") + ) + assertFalse( + "ISO instant should be older", + isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z") + ) + assertTrue( + "String comparison should work as fallback", + isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00") + ) + } + + @Test + fun `test empty data scenarios`() { + val emptyBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = emptyList(), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() + ) + + val dataBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = + listOf( + BackupGym( + id = "gym1", + name = "Test Gym", + location = null, + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = + listOf(DifficultySystem.V_SCALE), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() + ) + + // Test merging empty with data + val merged1 = performIntelligentMerge(emptyBackup, dataBackup) + assertEquals("Should preserve data from non-empty backup", 1, merged1.gyms.size) + + // Test merging data with empty + val merged2 = performIntelligentMerge(dataBackup, emptyBackup) + assertEquals("Should preserve data from non-empty backup", 1, merged2.gyms.size) + + // Test merging empty with empty + val merged3 = performIntelligentMerge(emptyBackup, emptyBackup) + assertEquals("Should remain empty", 0, merged3.gyms.size) + } + + // Helper methods that simulate the merge logic from SyncService + private fun performIntelligentMerge( + local: ClimbDataBackup, + server: ClimbDataBackup + ): ClimbDataBackup { + val mergedGyms = mergeGyms(local.gyms, server.gyms) + val mergedProblems = mergeProblems(local.problems, server.problems) + val mergedSessions = mergeSessions(local.sessions, server.sessions) + val mergedAttempts = mergeAttempts(local.attempts, server.attempts) + + return ClimbDataBackup( + exportedAt = "2024-01-01T12:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = mergedGyms, + problems = mergedProblems, + sessions = mergedSessions, + attempts = mergedAttempts + ) + } + + private fun mergeGyms(local: List, server: List): List { + val merged = mutableMapOf() + + // Add all local gyms + local.forEach { gym -> merged[gym.id] = gym } + + // Add server gyms, preferring newer updates + server.forEach { serverGym -> + val localGym = merged[serverGym.id] + if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) { + merged[serverGym.id] = serverGym + } + } + + return merged.values.toList() + } + + private fun mergeProblems( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local problems + local.forEach { problem -> merged[problem.id] = problem } + + // Add server problems, preferring newer updates + server.forEach { serverProblem -> + val localProblem = merged[serverProblem.id] + if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ) { + // Merge image paths to preserve all images + val allImagePaths = mutableSetOf() + localProblem?.imagePaths?.let { allImagePaths.addAll(it) } + serverProblem.imagePaths?.let { allImagePaths.addAll(it) } + + merged[serverProblem.id] = + serverProblem.withUpdatedImagePaths(allImagePaths.toList()) + } + } + + return merged.values.toList() + } + + private fun mergeSessions( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local sessions + local.forEach { session -> merged[session.id] = session } + + // Add server sessions, preferring newer updates + server.forEach { serverSession -> + val localSession = merged[serverSession.id] + if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt) + ) { + merged[serverSession.id] = serverSession + } + } + + return merged.values.toList() + } + + private fun mergeAttempts( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local attempts + local.forEach { attempt -> merged[attempt.id] = attempt } + + // Add server attempts, preferring newer updates + server.forEach { serverAttempt -> + val localAttempt = merged[serverAttempt.id] + if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) + ) { + merged[serverAttempt.id] = serverAttempt + } + } + + return merged.values.toList() + } + + private fun isNewerThan(dateString1: String, dateString2: String): Boolean { + return try { + // Try parsing as instant first + val date1 = java.time.Instant.parse(dateString1) + val date2 = java.time.Instant.parse(dateString2) + date1.isAfter(date2) + } catch (e: Exception) { + // Fallback to string comparison + dateString1 > dateString2 + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 38d2547..8b50bce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,6 +19,7 @@ kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" ksp = "2.2.10-2.0.2" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -65,6 +66,9 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } # Image Loading coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +# HTTP Client +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + [plugins] diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..178a9808377dbf28b3274a1a04062ad255c449b5 100644 GIT binary patch literal 554 zcmeH@I}XAy42Jid!gP-UQb*GTfy4j{CrE3PL<%tqLD1V1paWuNWc2^|Y#Dty#ZIAT zOC6R_B6sb)g}oHm$Tbm~w}|EyQP>NO(7QpRR_H1E<2dL%;a$R|U;v*F`lm z4atRc|FFyxT~TJbX{I$;I9sBS925Zx7u!dM-C?^1n+R4u%ZcHb11E|jaL$rz!!c-G MNq@qR{-Bh408TKpEC2ui literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/android/test_backup/ClimbRepository.kt b/android/test_backup/ClimbRepository.kt new file mode 100644 index 0000000..fe3483c --- /dev/null +++ b/android/test_backup/ClimbRepository.kt @@ -0,0 +1,383 @@ +package com.atridad.openclimb.data.repository + +import android.content.Context +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.utils.ZipExportImportUtils +import java.io.File +import java.time.LocalDateTime +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json + +class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { + private val gymDao = database.gymDao() + private val problemDao = database.problemDao() + private val sessionDao = database.climbSessionDao() + private val attemptDao = database.attemptDao() + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + // Gym operations + fun getAllGyms(): Flow> = gymDao.getAllGyms() + suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) + suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) + suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) + suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) + fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + + // Problem operations + fun getAllProblems(): Flow> = problemDao.getAllProblems() + suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) + fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) + suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) + suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) + suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) + fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + + // Session operations + fun getAllSessions(): Flow> = sessionDao.getAllSessions() + suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) + fun getSessionsByGym(gymId: String): Flow> = + sessionDao.getSessionsByGym(gymId) + suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() + fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() + suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) + suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) + suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) + suspend fun getLastUsedGym(): Gym? { + val recentSessions = sessionDao.getRecentSessions(1).first() + return if (recentSessions.isNotEmpty()) { + getGymById(recentSessions.first().gymId) + } else { + null + } + } + + // Attempt operations + fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() + fun getAttemptsBySession(sessionId: String): Flow> = + attemptDao.getAttemptsBySession(sessionId) + fun getAttemptsByProblem(problemId: String): Flow> = + attemptDao.getAttemptsByProblem(problemId) + suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) + suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) + suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) + + // ZIP Export with images - Single format for reliability + suspend fun exportAllDataToZip(directory: File? = null): File { + return try { + // Collect all data with proper error handling + 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 = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + // Log any missing images for debugging + val missingImages = referencedImagePaths - validImagePaths + if (missingImages.isNotEmpty()) { + android.util.Log.w( + "ClimbRepository", + "Some referenced images are missing: $missingImages" + ) + } + + ZipExportImportUtils.createExportZip( + context = context, + exportData = backupData, + referencedImagePaths = validImagePaths, + directory = directory + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + suspend fun exportAllDataToZipUri(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 = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = backupData, + referencedImagePaths = validImagePaths + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + 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) + } catch (e: Exception) { + 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) + importData.gyms.forEach { backupGym -> + try { + gymDao.insertGym(backupGym.toGym()) + } catch (e: Exception) { + throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") + } + } + + // Import problems with updated image paths + val updatedBackupProblems = + ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + importResult.importedImagePaths + ) + + // Import problems (depends on gyms) + updatedBackupProblems.forEach { backupProblem -> + try { + problemDao.insertProblem(backupProblem.toProblem()) + } catch (e: Exception) { + throw Exception( + "Failed to import problem '${backupProblem.name}': ${e.message}" + ) + } + } + + // Import sessions + importData.sessions.forEach { backupSession -> + try { + sessionDao.insertSession(backupSession.toClimbSession()) + } catch (e: Exception) { + throw Exception("Failed to import session '${backupSession.id}': ${e.message}") + } + } + + // Import attempts last (depends on problems and sessions) + importData.attempts.forEach { backupAttempt -> + try { + attemptDao.insertAttempt(backupAttempt.toAttempt()) + } catch (e: Exception) { + throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") + } + } + } catch (e: Exception) { + throw Exception("Import failed: ${e.message}") + } + } + + private fun validateDataIntegrity( + gyms: List, + problems: List, + 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()) { + throw Exception( + "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" + ) + } + + // Validate that all sessions reference valid gyms + val invalidSessions = sessions.filter { it.gymId !in gymIds } + if (invalidSessions.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" + ) + } + + // Validate that all attempts reference valid problems and sessions + val problemIds = problems.map { it.id }.toSet() + val sessionIds = sessions.map { it.id }.toSet() + + val invalidAttempts = + 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" + ) + } + } + + private fun validateImportData(importData: ClimbDataBackup) { + if (importData.gyms.isEmpty()) { + throw Exception("Import data is invalid: no gyms found") + } + + if (importData.version.isBlank()) { + 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 || + importData.attempts.size > 100000 + ) { + throw Exception("Import data is too large: possible corruption or malicious file") + } + } + + suspend fun resetAllData() { + try { + // Clear all data from database + attemptDao.deleteAllAttempts() + sessionDao.deleteAllSessions() + problemDao.deleteAllProblems() + gymDao.deleteAllGyms() + + // Clear all images from storage + clearAllImages() + } catch (e: Exception) { + throw Exception("Reset failed: ${e.message}") + } + } + + 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 + imagesDir.deleteRecursively() + android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") + } + } catch (e: Exception) { + android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") + } + } +} diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index f8e7793..63bf078 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -381,6 +382,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -394,7 +396,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -414,7 +416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -437,7 +439,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -457,7 +459,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -479,7 +481,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -490,7 +492,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -509,7 +511,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -520,7 +522,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 6acfc545a745e71e6ae926132a20c2b0aaf0705a..7fb5b300dda0de0183e6f123017578973ff51bfb 100644 GIT binary patch literal 124606 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�}3 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 SKNh?AfB*6S|M#H*5C0#I_{M?& literal 115423 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%0AgJ)JPyYM&F& zX7*x6)sAw4vz+G=m-&}_K}hvX*kSd`m`nBlVIS4UGL7l{!c3N+x9ZCvcA?_T3+ z5aR#58d5VgX~{rle3v!de9bV-w5BXI>*M{Jk@!Aqj%Efj)Qn*ZTiK3#s;NfJTR})I zZ`SfwEpOGzi~ZE9&bxey_iA;;tZQ{+0AJ%9sx_SNaKp9Ct=2piuoyF|WoEV9P_1>S zTk9|~*RrEpa@TUdwI1-8lpv&b2%)4SBU#8!QHoQNGL)whRjEO3eD}5WT)Q(v`H{Jp zcWv2g?_xK5IfnYRPx2%PsbdCp)T@&THS3r`okF<9I>mUNQq-X-&1g;sdeDbR#m3t2@Bv6xLAJE>zfb#|b?I{R=>b@W!}4Cgq{4es*@cTv|})HSEN z`mU?zy5>~Z9_yM(T|28gky$KaDJzIZ?Yj0+cO&LfcN>4QlPf_;y-?(>SCN`DLgsp} z(SrZchR^sMnd`ZSdhVg#_o!R%C+4DVJ$37;ThAWqtwr5>>ef@Yp1SpRv4?9xNPT(g zm!=BU=*a*Eq38O3r@mR$zk?ane-?x^D1$y5G~oqa;#J<@Z9b+IZLzNg?xTVEG|+p4 zDd?qPYUFJA4I>%PBxdj%i;$sVBJ24Bdun))Q(VIA8r};+8f73e*|3jBVdSL%g(*sL z^xVkZHmXMh8qkw$~0E9hrff6ChosUG0ISm>gcseL(IB~SvS#Zla_qMr?jCx=G!ES z?)0J$`fjp|he1fwRD_ZPvuSEJP0gmM*);W?H7!d8DpM8TT~jyM^wl7w`GzIS%vS8N`Je3MW)SjH9^Ao8pJCoF4PZFm;hmSv=fA%< b&wu|2_5UC9-#-cu`G5cV|Nk2DQtbZ$GqODt diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme index e69de29..8f80ddb 100644 --- a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme +++ b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index d99571e..21a4b03 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -57,6 +57,8 @@ struct ContentView: View { } .onAppear { setupNotificationObservers() + // Trigger auto-sync on app launch + dataManager.syncService.triggerAutoSync(dataManager: dataManager) } .onDisappear { removeNotificationObservers() @@ -100,7 +102,9 @@ struct ContentView: View { print("📱 App did become active - checking Live Activity status") Task { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds - dataManager.onAppBecomeActive() + await dataManager.onAppBecomeActive() + // Trigger auto-sync when app becomes active + await dataManager.syncService.triggerAutoSync(dataManager: dataManager) } } diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift new file mode 100644 index 0000000..a0dcb3c --- /dev/null +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -0,0 +1,447 @@ +// +// BackupFormat.swift + +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 { + let exportedAt: String + let version: String + let formatVersion: String + let gyms: [BackupGym] + let problems: [BackupProblem] + let sessions: [BackupClimbSession] + let attempts: [BackupAttempt] + + init( + exportedAt: String, + version: String = "2.0", + formatVersion: String = "2.0", + gyms: [BackupGym], + problems: [BackupProblem], + sessions: [BackupClimbSession], + attempts: [BackupAttempt] + ) { + self.exportedAt = exportedAt + self.version = version + self.formatVersion = formatVersion + self.gyms = gyms + self.problems = problems + self.sessions = sessions + self.attempts = attempts + } +} + +/// Platform-neutral gym representation for backup/restore +struct BackupGym: Codable { + let id: String + let name: String + let location: String? + let supportedClimbTypes: [ClimbType] + let difficultySystems: [DifficultySystem] + let customDifficultyGrades: [String] + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Gym model + init(from gym: Gym) { + self.id = gym.id.uuidString + self.name = gym.name + self.location = gym.location + self.supportedClimbTypes = gym.supportedClimbTypes + self.difficultySystems = gym.difficultySystems + self.customDifficultyGrades = gym.customDifficultyGrades + self.notes = gym.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.createdAt = formatter.string(from: gym.createdAt) + self.updatedAt = formatter.string(from: gym.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + name: String, + location: String?, + supportedClimbTypes: [ClimbType], + difficultySystems: [DifficultySystem], + customDifficultyGrades: [String] = [], + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.name = name + self.location = location + self.supportedClimbTypes = supportedClimbTypes + self.difficultySystems = difficultySystems + self.customDifficultyGrades = customDifficultyGrades + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Gym model + func toGym() throws -> Gym { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + return Gym.fromImport( + id: uuid, + name: name, + location: location, + supportedClimbTypes: supportedClimbTypes, + difficultySystems: difficultySystems, + customDifficultyGrades: customDifficultyGrades, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral problem representation for backup/restore +struct BackupProblem: Codable { + let id: String + let gymId: String + let name: String? + let description: String? + let climbType: ClimbType + let difficulty: DifficultyGrade + let tags: [String] + let location: String? + let imagePaths: [String]? + let isActive: Bool + let dateSet: String? // ISO 8601 format + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Problem model + init(from problem: Problem) { + self.id = problem.id.uuidString + self.gymId = problem.gymId.uuidString + self.name = problem.name + self.description = problem.description + self.climbType = problem.climbType + self.difficulty = problem.difficulty + self.tags = problem.tags + self.location = problem.location + self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths + self.isActive = problem.isActive + self.notes = problem.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.dateSet = problem.dateSet.map { formatter.string(from: $0) } + self.createdAt = formatter.string(from: problem.createdAt) + self.updatedAt = formatter.string(from: problem.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + name: String?, + description: String?, + climbType: ClimbType, + difficulty: DifficultyGrade, + tags: [String] = [], + location: String?, + imagePaths: [String]?, + isActive: Bool, + dateSet: String?, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.name = name + self.description = description + self.climbType = climbType + self.difficulty = difficulty + self.tags = tags + self.location = location + self.imagePaths = imagePaths + self.isActive = isActive + self.dateSet = dateSet + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Problem model + func toProblem() throws -> Problem { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let dateSetDate = dateSet.flatMap { formatter.date(from: $0) } + + return Problem.fromImport( + id: uuid, + gymId: gymUuid, + name: name, + description: description, + climbType: climbType, + difficulty: difficulty, + tags: tags, + location: location, + imagePaths: imagePaths ?? [], + isActive: isActive, + dateSet: dateSetDate, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } + + /// Create a copy with updated image paths for import processing + func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem { + return BackupProblem( + id: self.id, + gymId: self.gymId, + name: self.name, + description: self.description, + climbType: self.climbType, + difficulty: self.difficulty, + tags: self.tags, + location: self.location, + imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, + isActive: self.isActive, + dateSet: self.dateSet, + notes: self.notes, + createdAt: self.createdAt, + updatedAt: self.updatedAt + ) + } +} + +/// Platform-neutral climb session representation for backup/restore +struct BackupClimbSession: Codable { + let id: String + let gymId: String + let date: String // ISO 8601 format + let startTime: String? // ISO 8601 format + let endTime: String? // ISO 8601 format + let duration: Int64? // Duration in seconds + let status: SessionStatus + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS ClimbSession model + init(from session: ClimbSession) { + self.id = session.id.uuidString + self.gymId = session.gymId.uuidString + self.status = session.status + self.notes = session.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.date = formatter.string(from: session.date) + self.startTime = session.startTime.map { formatter.string(from: $0) } + self.endTime = session.endTime.map { formatter.string(from: $0) } + self.duration = session.duration.map { Int64($0) } + self.createdAt = formatter.string(from: session.createdAt) + self.updatedAt = formatter.string(from: session.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + date: String, + startTime: String?, + endTime: String?, + duration: Int64?, + status: SessionStatus, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.date = date + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.status = status + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS ClimbSession model + func toClimbSession() throws -> ClimbSession { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let dateValue = formatter.date(from: date), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let startTimeValue = startTime.flatMap { formatter.date(from: $0) } + let endTimeValue = endTime.flatMap { formatter.date(from: $0) } + let durationValue = duration.map { Int($0) } + + return ClimbSession.fromImport( + id: uuid, + gymId: gymUuid, + date: dateValue, + startTime: startTimeValue, + endTime: endTimeValue, + duration: durationValue, + status: status, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral attempt representation for backup/restore +struct BackupAttempt: Codable { + let id: String + let sessionId: String + let problemId: String + let result: AttemptResult + let highestHold: String? + 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 + + /// Initialize from native iOS Attempt model + init(from attempt: Attempt) { + self.id = attempt.id.uuidString + self.sessionId = attempt.sessionId.uuidString + self.problemId = attempt.problemId.uuidString + self.result = attempt.result + self.highestHold = attempt.highestHold + self.notes = attempt.notes + self.duration = attempt.duration.map { Int64($0) } + self.restTime = attempt.restTime.map { Int64($0) } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.timestamp = formatter.string(from: attempt.timestamp) + self.createdAt = formatter.string(from: attempt.createdAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String?, + notes: String?, + duration: Int64?, + restTime: Int64?, + timestamp: String, + createdAt: String + ) { + self.id = id + self.sessionId = sessionId + self.problemId = problemId + self.result = result + self.highestHold = highestHold + self.notes = notes + self.duration = duration + self.restTime = restTime + self.timestamp = timestamp + self.createdAt = createdAt + } + + /// Convert to native iOS Attempt model + func toAttempt() throws -> Attempt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let sessionUuid = UUID(uuidString: sessionId), + let problemUuid = UUID(uuidString: problemId), + let timestampDate = formatter.date(from: timestamp), + let createdDate = formatter.date(from: createdAt) + else { + throw BackupError.invalidDateFormat + } + + let durationValue = duration.map { Int($0) } + let restTimeValue = restTime.map { Int($0) } + + return Attempt.fromImport( + id: uuid, + sessionId: sessionUuid, + problemId: problemUuid, + result: result, + highestHold: highestHold, + notes: notes, + duration: durationValue, + restTime: restTimeValue, + timestamp: timestampDate, + createdAt: createdDate + ) + } +} + +// MARK: - Backup Format Errors + +enum BackupError: LocalizedError { + case invalidDateFormat + case invalidUUID + case missingRequiredField(String) + case unsupportedFormatVersion(String) + + var errorDescription: String? { + switch self { + case .invalidDateFormat: + return "Invalid date format in backup data" + case .invalidUUID: + return "Invalid UUID format in backup data" + case .missingRequiredField(let field): + return "Missing required field: \(field)" + case .unsupportedFormatVersion(let version): + return "Unsupported backup format version: \(version)" + } + } +} + +// MARK: - Extensions + +// MARK: - Helper Extensions for Optional Mapping + +extension Optional { + func map(_ transform: (Wrapped) -> T) -> T? { + return self.flatMap { .some(transform($0)) } + } +} diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift new file mode 100644 index 0000000..a88ccc4 --- /dev/null +++ b/ios/OpenClimb/Services/SyncService.swift @@ -0,0 +1,978 @@ +import Combine +import Foundation +import UIKit + +@MainActor +class SyncService: ObservableObject { + + @Published var isSyncing = false + @Published var lastSyncTime: Date? + @Published var syncError: String? + @Published var isConnected = false + @Published var isTesting = false + + private let userDefaults = UserDefaults.standard + + private enum Keys { + static let serverURL = "sync_server_url" + static let authToken = "sync_auth_token" + static let lastSyncTime = "last_sync_time" + static let isConnected = "sync_is_connected" + static let autoSyncEnabled = "auto_sync_enabled" + } + + var serverURL: String { + get { userDefaults.string(forKey: Keys.serverURL) ?? "" } + set { userDefaults.set(newValue, forKey: Keys.serverURL) } + } + + var authToken: String { + get { userDefaults.string(forKey: Keys.authToken) ?? "" } + set { userDefaults.set(newValue, forKey: Keys.authToken) } + } + + var isConfigured: Bool { + return !serverURL.isEmpty && !authToken.isEmpty + } + + var isAutoSyncEnabled: Bool { + get { userDefaults.bool(forKey: Keys.autoSyncEnabled) } + set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) } + } + + init() { + if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { + self.lastSyncTime = lastSync + } + self.isConnected = userDefaults.bool(forKey: Keys.isConnected) + } + + func downloadData() async throws -> ClimbDataBackup { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/sync") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + default: + throw SyncError.serverError(httpResponse.statusCode) + } + + do { + let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return backup + } catch { + throw SyncError.decodingError(error) + } + } + + func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/sync") else { + throw SyncError.invalidURL + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let jsonData = try encoder.encode(backup) + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + case 400: + throw SyncError.badRequest + default: + throw SyncError.serverError(httpResponse.statusCode) + } + + do { + let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return responseBackup + } catch { + throw SyncError.decodingError(error) + } + } + + func uploadImage(filename: String, imageData: Data) async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + default: + throw SyncError.serverError(httpResponse.statusCode) + } + } + + func downloadImage(filename: String) async throws -> Data { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + + return data + case 401: + + throw SyncError.unauthorized + case 404: + + throw SyncError.imageNotFound + default: + + throw SyncError.serverError(httpResponse.statusCode) + } + } + + func syncWithServer(dataManager: ClimbingDataManager) async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard isConnected else { + throw SyncError.notConnected + } + + isSyncing = true + syncError = nil + + defer { + isSyncing = false + } + + do { + // Get local backup data + let localBackup = createBackupFromDataManager(dataManager) + + // Download server data + let serverBackup = try await downloadData() + + // Check if we have any local data + let hasLocalData = + !dataManager.gyms.isEmpty || !dataManager.problems.isEmpty + || !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty + + let hasServerData = + !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty + || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty + + 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...") + let imagePathMapping = try await syncImagesFromServer( + backup: serverBackup, dataManager: dataManager) + print("Importing data after images...") + try importBackupToDataManager( + serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) + print("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") + let currentBackup = createBackupFromDataManager(dataManager) + _ = try await uploadData(currentBackup) + print("Uploading local images to server...") + try await syncImagesToServer(dataManager: dataManager) + print("Initial upload completed") + } else if hasLocalData && hasServerData { + // Case 3: Both have data - compare timestamps (last writer wins) + let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt) + let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt) + + print("🕐 DEBUG iOS Timestamp Comparison:") + print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)") + print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") + print( + " DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'" + ) + print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)") + + if localTimestamp > serverTimestamp { + // Local is newer - replace server with local data + print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content") + let currentBackup = createBackupFromDataManager(dataManager) + _ = try await uploadData(currentBackup) + try await syncImagesToServer(dataManager: dataManager) + print("Server replaced with local data") + } else if serverTimestamp > localTimestamp { + // Server is newer - replace local with server data + print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content") + let imagePathMapping = try await syncImagesFromServer( + backup: serverBackup, dataManager: dataManager) + try importBackupToDataManager( + serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) + print("Local data replaced with server data") + } else { + // Timestamps are equal - no sync needed + print( + "🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed" + ) + } + } else { + print("No data to sync") + } + + // Update last sync time + lastSyncTime = Date() + userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) + + } catch { + syncError = error.localizedDescription + throw error + } + } + + /// Parses ISO8601 timestamp to milliseconds for comparison + private func parseISO8601ToMillis(timestamp: String) -> Int64 { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: timestamp) { + return Int64(date.timeIntervalSince1970 * 1000) + } + print("Failed to parse timestamp: \(timestamp), using 0") + return 0 + } + + private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) + async throws -> [String: String] + { + var imagePathMapping: [String: String] = [:] + + // Process images by problem to maintain consistent naming + for problem in backup.problems { + guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } + + for (index, imagePath) in imagePaths.enumerated() { + let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent + + do { + let imageData = try await downloadImage(filename: serverFilename) + + // Generate consistent filename if needed + let consistentFilename = + ImageNamingUtils.isValidImageFilename(serverFilename) + ? serverFilename + : ImageNamingUtils.generateImageFilename( + problemId: problem.id, imageIndex: index) + + // Save image with consistent filename + let imageManager = ImageManager.shared + _ = try imageManager.saveImportedImage( + imageData, filename: consistentFilename) + + // Map server filename to consistent local filename + imagePathMapping[serverFilename] = consistentFilename + print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") + } catch SyncError.imageNotFound { + print("Image not found on server: \(serverFilename)") + continue + } catch { + print("Failed to download image \(serverFilename): \(error)") + continue + } + } + } + + return imagePathMapping + } + + private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { + // Process images by problem to ensure consistent naming + for problem in dataManager.problems { + guard !problem.imagePaths.isEmpty else { continue } + + for (index, imagePath) in problem.imagePaths.enumerated() { + let filename = URL(fileURLWithPath: imagePath).lastPathComponent + + // Ensure filename follows consistent naming convention + let consistentFilename = + ImageNamingUtils.isValidImageFilename(filename) + ? filename + : ImageNamingUtils.generateImageFilename( + problemId: problem.id.uuidString, imageIndex: index) + + // Load image data + let imageManager = ImageManager.shared + let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path + + if let imageData = imageManager.loadImageData(fromPath: fullPath) { + do { + // If filename changed, rename local file + if filename != consistentFilename { + let newPath = imageManager.imagesDirectory.appendingPathComponent( + consistentFilename + ).path + do { + try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) + print("Renamed local image: \(filename) -> \(consistentFilename)") + + // Update problem's image path in memory for consistency + // Note: This would require updating the problem in the data manager + } catch { + print("Failed to rename local image, using original: \(error)") + } + } + + try await uploadImage(filename: consistentFilename, imageData: imageData) + print("Successfully uploaded image: \(consistentFilename)") + } catch { + print("Failed to upload image \(consistentFilename): \(error)") + // Continue with other images even if one fails + } + } + } + } + } + + private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup + { + return ClimbDataBackup( + exportedAt: DataStateManager.shared.getLastModified(), + gyms: dataManager.gyms.map { BackupGym(from: $0) }, + problems: dataManager.problems.map { BackupProblem(from: $0) }, + sessions: dataManager.sessions.map { BackupClimbSession(from: $0) }, + attempts: dataManager.attempts.map { BackupAttempt(from: $0) } + ) + } + + private func importBackupToDataManager( + _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, + imagePathMapping: [String: String] = [:] + ) throws { + do { + + // Update problem image paths to point to downloaded images + let updatedBackup: ClimbDataBackup + if !imagePathMapping.isEmpty { + let updatedProblems = backup.problems.map { problem in + let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in + imagePathMapping[oldPath] ?? oldPath + } + return BackupProblem( + id: problem.id, + gymId: problem.gymId, + name: problem.name, + description: problem.description, + climbType: problem.climbType, + difficulty: problem.difficulty, + tags: problem.tags, + location: problem.location, + imagePaths: updatedImagePaths, + isActive: problem.isActive, + dateSet: problem.dateSet, + notes: problem.notes, + createdAt: problem.createdAt, + updatedAt: problem.updatedAt + ) + } + updatedBackup = ClimbDataBackup( + exportedAt: backup.exportedAt, + version: backup.version, + formatVersion: backup.formatVersion, + gyms: backup.gyms, + problems: updatedProblems, + sessions: backup.sessions, + attempts: backup.attempts + ) + + } else { + updatedBackup = backup + } + + // Create a minimal ZIP with just the JSON data for existing import mechanism + let zipData = try createMinimalZipFromBackup(updatedBackup) + + // Use existing import method which properly handles data restoration + try dataManager.importData(from: zipData) + + // Update local data state to match imported data timestamp + DataStateManager.shared.setLastModified(backup.exportedAt) + print("Data state synchronized to imported timestamp: \(backup.exportedAt)") + + } catch { + + throw SyncError.importFailed(error) + } + } + + private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data { + // Create JSON data + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .custom { date, encoder in + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date)) + } + let jsonData = try encoder.encode(backup) + + // Collect all downloaded images from ImageManager + let imageManager = ImageManager.shared + var imageFiles: [(filename: String, data: Data)] = [] + let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] }) + + for imagePath in imagePaths { + let filename = URL(fileURLWithPath: imagePath).lastPathComponent + let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path + if let imageData = imageManager.loadImageData(fromPath: fullPath) { + imageFiles.append((filename: filename, data: imageData)) + + } + } + + // Create ZIP with data.json, metadata, and images + var zipData = Data() + var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] + var currentOffset: UInt32 = 0 + + // Add data.json to ZIP + try addFileToMinimalZip( + filename: "data.json", + fileData: jsonData, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + + // Add metadata with correct image count + let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)" + let metadataData = metadata.data(using: .utf8) ?? Data() + try addFileToMinimalZip( + filename: "metadata.txt", + fileData: metadataData, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + + // Add images to ZIP in images/ directory + for imageFile in imageFiles { + try addFileToMinimalZip( + filename: "images/\(imageFile.filename)", + fileData: imageFile.data, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + } + + // Add central directory + var centralDirectory = Data() + for entry in fileEntries { + centralDirectory.append(createCentralDirectoryHeader(entry: entry)) + } + + // Add end of central directory record + let endOfCentralDir = createEndOfCentralDirectoryRecord( + fileCount: UInt16(fileEntries.count), + centralDirSize: UInt32(centralDirectory.count), + centralDirOffset: currentOffset + ) + + zipData.append(centralDirectory) + zipData.append(endOfCentralDir) + + return zipData + } + + private func addFileToMinimalZip( + filename: String, + fileData: Data, + zipData: inout Data, + fileEntries: inout [(name: String, data: Data, offset: UInt32)], + currentOffset: inout UInt32 + ) throws { + let localFileHeader = createLocalFileHeader( + filename: filename, fileSize: UInt32(fileData.count)) + + fileEntries.append((name: filename, data: fileData, offset: currentOffset)) + + zipData.append(localFileHeader) + zipData.append(fileData) + + currentOffset += UInt32(localFileHeader.count + fileData.count) + } + + private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data { + var header = Data() + + // Local file header signature + header.append(Data([0x50, 0x4b, 0x03, 0x04])) + + // Version needed to extract (2.0) + header.append(Data([0x14, 0x00])) + + // General purpose bit flag + header.append(Data([0x00, 0x00])) + + // Compression method (no compression) + header.append(Data([0x00, 0x00])) + + // Last mod file time & date (dummy values) + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // CRC-32 (dummy - we're not compressing) + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Compressed size + withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } + + // Uncompressed size + withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } + + // File name length + let filenameData = filename.data(using: .utf8) ?? Data() + let filenameLength = UInt16(filenameData.count) + withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } + + // Extra field length + header.append(Data([0x00, 0x00])) + + // File name + header.append(filenameData) + + return header + } + + private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32)) + -> Data + { + var header = Data() + + // Central directory signature + header.append(Data([0x50, 0x4b, 0x01, 0x02])) + + // Version made by + header.append(Data([0x14, 0x00])) + + // Version needed to extract + header.append(Data([0x14, 0x00])) + + // General purpose bit flag + header.append(Data([0x00, 0x00])) + + // Compression method + header.append(Data([0x00, 0x00])) + + // Last mod file time & date + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // CRC-32 + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Compressed size + let compressedSize = UInt32(entry.data.count) + withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } + + // Uncompressed size + withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } + + // File name length + let filenameData = entry.name.data(using: .utf8) ?? Data() + let filenameLength = UInt16(filenameData.count) + withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } + + // Extra field length + header.append(Data([0x00, 0x00])) + + // File comment length + header.append(Data([0x00, 0x00])) + + // Disk number start + header.append(Data([0x00, 0x00])) + + // Internal file attributes + header.append(Data([0x00, 0x00])) + + // External file attributes + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Relative offset of local header + withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) } + + // File name + header.append(filenameData) + + return header + } + + private func createEndOfCentralDirectoryRecord( + fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 + ) -> Data { + var record = Data() + + // End of central dir signature + record.append(Data([0x50, 0x4b, 0x05, 0x06])) + + // Number of this disk + record.append(Data([0x00, 0x00])) + + // Number of the disk with the start of the central directory + record.append(Data([0x00, 0x00])) + + // Total number of entries in the central directory on this disk + withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } + + // Total number of entries in the central directory + withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } + + // Size of the central directory + withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) } + + // Offset of start of central directory + withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) } + + // ZIP file comment length + record.append(Data([0x00, 0x00])) + + return record + } + + func testConnection() async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + isTesting = true + defer { isTesting = false } + + guard let url = URL(string: "\(serverURL)/health") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw SyncError.serverError(httpResponse.statusCode) + } + + // Connection successful, mark as connected + isConnected = true + userDefaults.set(true, forKey: Keys.isConnected) + } + + func triggerAutoSync(dataManager: ClimbingDataManager) { + guard isConnected && isConfigured && isAutoSyncEnabled else { return } + + Task { + do { + try await syncWithServer(dataManager: dataManager) + } catch { + print("Auto-sync failed: \(error)") + // Don't show UI errors for auto-sync failures + } + } + } + + // DEPRECATED: Complex merge logic replaced with simple timestamp-based sync + // These methods are no longer used but kept for reference + @available(*, deprecated, message: "Use simple timestamp-based sync instead") + private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws + -> ClimbDataBackup + { + print("Merging data - preserving all entities to prevent data loss") + + // Merge gyms by ID, keeping most recently updated + let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms) + + // Merge problems by ID, keeping most recently updated + let mergedProblems = mergeProblems(local: local.problems, server: server.problems) + + // Merge sessions by ID, keeping most recently updated + let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions) + + // Merge attempts by ID, keeping most recently updated + let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts) + + print( + "Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)" + ) + + return ClimbDataBackup( + exportedAt: ISO8601DateFormatter().string(from: Date()), + version: "2.0", + formatVersion: "2.0", + gyms: mergedGyms, + problems: mergedProblems, + sessions: mergedSessions, + attempts: mergedAttempts + ) + } + + private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] { + var merged: [String: BackupGym] = [:] + + // Add all local gyms + for gym in local { + merged[gym.id] = gym + } + + // Add server gyms, replacing if newer + for serverGym in server { + if let localGym = merged[serverGym.id] { + // Keep the most recently updated + if isNewerThan(serverGym.updatedAt, localGym.updatedAt) { + merged[serverGym.id] = serverGym + } + } else { + // New gym from server + merged[serverGym.id] = serverGym + } + } + + return Array(merged.values) + } + + private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] { + var merged: [String: BackupProblem] = [:] + + // Add all local problems + for problem in local { + merged[problem.id] = problem + } + + // Add server problems, replacing if newer or merging image paths + for serverProblem in server { + if let localProblem = merged[serverProblem.id] { + // Merge image paths from both sources + let localImages = Set(localProblem.imagePaths ?? []) + let serverImages = Set(serverProblem.imagePaths ?? []) + let mergedImages = Array(localImages.union(serverImages)) + + // Use most recently updated problem data but with merged images + let newerProblem = + isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ? serverProblem : localProblem + merged[serverProblem.id] = BackupProblem( + id: newerProblem.id, + gymId: newerProblem.gymId, + name: newerProblem.name, + description: newerProblem.description, + climbType: newerProblem.climbType, + difficulty: newerProblem.difficulty, + tags: newerProblem.tags, + location: newerProblem.location, + imagePaths: mergedImages.isEmpty ? nil : mergedImages, + isActive: newerProblem.isActive, + dateSet: newerProblem.dateSet, + notes: newerProblem.notes, + createdAt: newerProblem.createdAt, + updatedAt: newerProblem.updatedAt + ) + } else { + // New problem from server + merged[serverProblem.id] = serverProblem + } + } + + return Array(merged.values) + } + + private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession]) + -> [BackupClimbSession] + { + var merged: [String: BackupClimbSession] = [:] + + // Add all local sessions + for session in local { + merged[session.id] = session + } + + // Add server sessions, replacing if newer + for serverSession in server { + if let localSession = merged[serverSession.id] { + // Keep the most recently updated + if isNewerThan(serverSession.updatedAt, localSession.updatedAt) { + merged[serverSession.id] = serverSession + } + } else { + // New session from server + merged[serverSession.id] = serverSession + } + } + + return Array(merged.values) + } + + private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] { + var merged: [String: BackupAttempt] = [:] + + // Add all local attempts + for attempt in local { + merged[attempt.id] = attempt + } + + // Add server attempts, replacing if newer + for serverAttempt in server { + if let localAttempt = merged[serverAttempt.id] { + // Keep the most recently created (attempts don't typically get updated) + if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) { + merged[serverAttempt.id] = serverAttempt + } + } else { + // New attempt from server + merged[serverAttempt.id] = serverAttempt + } + } + + return Array(merged.values) + } + + private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool { + let formatter = ISO8601DateFormatter() + guard let date1 = formatter.date(from: dateString1), + let date2 = formatter.date(from: dateString2) + else { + return false + } + return date1 > date2 + } + + func disconnect() { + isConnected = false + lastSyncTime = nil + syncError = nil + userDefaults.set(false, forKey: Keys.isConnected) + userDefaults.removeObject(forKey: Keys.lastSyncTime) + } + + func clearConfiguration() { + serverURL = "" + authToken = "" + lastSyncTime = nil + isConnected = false + isAutoSyncEnabled = true + userDefaults.removeObject(forKey: Keys.lastSyncTime) + userDefaults.removeObject(forKey: Keys.isConnected) + userDefaults.removeObject(forKey: Keys.autoSyncEnabled) + } +} + +// Removed SyncTrigger enum - now using simple auto sync on any data change + +enum SyncError: LocalizedError { + case notConfigured + case notConnected + case invalidURL + case invalidResponse + case unauthorized + case badRequest + case serverError(Int) + case decodingError(Error) + case exportFailed + case importFailed(Error) + case imageNotFound + case imageUploadFailed + + var errorDescription: String? { + switch self { + case .notConfigured: + return "Sync server not configured. Please set server URL and auth token." + case .notConnected: + return "Not connected to sync server. Please test connection first." + case .invalidURL: + return "Invalid server URL." + case .invalidResponse: + return "Invalid response from server." + case .unauthorized: + return "Authentication failed. Check your auth token." + case .badRequest: + return "Bad request. Check your data format." + case .serverError(let code): + return "Server error (code \(code))." + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .exportFailed: + return "Failed to export local data." + case .importFailed(let error): + return "Failed to import data: \(error.localizedDescription)" + case .imageNotFound: + return "Image not found on server." + case .imageUploadFailed: + return "Failed to upload image to server." + } + } +} diff --git a/ios/OpenClimb/Utils/DataStateManager.swift b/ios/OpenClimb/Utils/DataStateManager.swift new file mode 100644 index 0000000..d533284 --- /dev/null +++ b/ios/OpenClimb/Utils/DataStateManager.swift @@ -0,0 +1,85 @@ +// +// DataStateManager.swift + +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. +class DataStateManager { + + private let userDefaults = UserDefaults.standard + + private enum Keys { + static let lastModified = "openclimb_data_last_modified" + static let initialized = "openclimb_data_state_initialized" + } + + /// Shared instance for app-wide use + static let shared = DataStateManager() + + private init() { + // Initialize with current timestamp if this is the first time + if !isInitialized() { + print("DataStateManager: First time initialization") + // 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)") + } else { + print("DataStateManager: Already initialized, current timestamp: \(getLastModified())") + } + } + + /// Updates the data state timestamp to the current time. Call this whenever any data is modified + /// (create, update, delete). + func updateDataState() { + let now = ISO8601DateFormatter().string(from: Date()) + userDefaults.set(now, forKey: Keys.lastModified) + print("📝 iOS Data state updated to: \(now)") + } + + /// Gets the current data state timestamp. This represents when any data was last modified + /// locally. + func getLastModified() -> String { + if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { + print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)") + return storedTimestamp + } + + // If no timestamp is stored, return epoch time to indicate very old data + // This ensures server data will be considered newer than uninitialized local data + let epochTime = "1970-01-01T00:00:00.000Z" + print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)") + return epochTime + } + + /// Sets the data state timestamp to a specific value. Used when importing data from server to + /// sync the state. + func setLastModified(_ timestamp: String) { + userDefaults.set(timestamp, forKey: Keys.lastModified) + print("Data state set to: \(timestamp)") + } + + /// Resets the data state (for testing or complete data wipe). + func reset() { + userDefaults.removeObject(forKey: Keys.lastModified) + userDefaults.removeObject(forKey: Keys.initialized) + print("Data state reset") + } + + /// Checks if the data state has been initialized. + private func isInitialized() -> Bool { + return userDefaults.bool(forKey: Keys.initialized) + } + + /// Marks the data state as initialized. + private func markAsInitialized() { + userDefaults.set(true, forKey: Keys.initialized) + } + + /// Gets debug information about the current state. + func getDebugInfo() -> String { + return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))" + } +} diff --git a/ios/OpenClimb/Utils/ImageNamingUtils.swift b/ios/OpenClimb/Utils/ImageNamingUtils.swift new file mode 100644 index 0000000..8aca2d1 --- /dev/null +++ b/ios/OpenClimb/Utils/ImageNamingUtils.swift @@ -0,0 +1,176 @@ +// +// ImageNamingUtils.swift + +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. +class ImageNamingUtils { + + private static let imageExtension = ".jpg" + private static let hashLength = 12 // First 12 chars of SHA-256 + + /// 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 + 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 + 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 + 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: "_") + + guard parts.count == 3 && parts[0] == "problem" else { + 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 + static func isValidImageFilename(_ filename: String) -> Bool { + guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { + return false + } + + let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) + let parts = nameWithoutExtension.components(separatedBy: "_") + + return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength + && 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 + 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 + private static func createHash(from input: String) -> String { + let inputData = Data(input.utf8) + let hashed = SHA256.hash(data: inputData) + let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined() + 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 + static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String: + String] + { + var renameMap: [String: String] = [:] + + for (index, oldFilename) in existingFilenames.enumerated() { + let newFilename = migrateFilename( + oldFilename: oldFilename, problemId: problemId, imageIndex: index) + if newFilename != oldFilename { + renameMap[oldFilename] = newFilename + } + } + + return renameMap + } + + /// Validates that a collection of filenames follow our naming convention. + /// + /// - Parameter filenames: Array of filenames to validate + /// - Returns: Dictionary with validation results + static func validateFilenames(_ filenames: [String]) -> ImageValidationResult { + var validImages: [String] = [] + var invalidImages: [String] = [] + + for filename in filenames { + if isValidImageFilename(filename) { + validImages.append(filename) + } else { + invalidImages.append(filename) + } + } + + return ImageValidationResult( + totalImages: filenames.count, + validImages: validImages, + invalidImages: invalidImages + ) + } +} + +/// Result of image filename validation +struct ImageValidationResult { + let totalImages: Int + let validImages: [String] + let invalidImages: [String] + + var isAllValid: Bool { + return invalidImages.isEmpty + } + + var validPercentage: Double { + guard totalImages > 0 else { return 100.0 } + return (Double(validImages.count) / Double(totalImages)) * 100.0 + } +} diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index ebdbd26..64d4fbd 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,4 +1,3 @@ - import Compression import Foundation import zlib @@ -10,7 +9,7 @@ struct ZipUtils { private static let METADATA_FILENAME = "metadata.txt" static func createExportZip( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) throws -> Data { @@ -196,7 +195,7 @@ struct ZipUtils { } private static func createMetadata( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) -> String { return """ diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 112f549..3cad61f 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -29,6 +29,9 @@ class ClimbingDataManager: ObservableObject { private let decoder = JSONDecoder() private var liveActivityObserver: NSObjectProtocol? + // Sync service for automatic syncing + let syncService = SyncService() + private enum Keys { static let gyms = "openclimb_gyms" static let problems = "openclimb_problems" @@ -200,6 +203,7 @@ class ClimbingDataManager: ObservableObject { func addGym(_ gym: Gym) { gyms.append(gym) saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym added successfully" clearMessageAfterDelay() } @@ -208,6 +212,7 @@ class ClimbingDataManager: ObservableObject { if let index = gyms.firstIndex(where: { $0.id == gym.id }) { gyms[index] = gym saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym updated successfully" clearMessageAfterDelay() } @@ -229,6 +234,7 @@ class ClimbingDataManager: ObservableObject { // Delete the gym gyms.removeAll { $0.id == gym.id } saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym deleted successfully" clearMessageAfterDelay() } @@ -240,14 +246,19 @@ class ClimbingDataManager: ObservableObject { func addProblem(_ problem: Problem) { problems.append(problem) saveProblems() + DataStateManager.shared.updateDataState() successMessage = "Problem added successfully" clearMessageAfterDelay() + + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) } func updateProblem(_ problem: Problem) { if let index = problems.firstIndex(where: { $0.id == problem.id }) { problems[index] = problem saveProblems() + DataStateManager.shared.updateDataState() successMessage = "Problem updated successfully" clearMessageAfterDelay() } @@ -264,6 +275,7 @@ class ClimbingDataManager: ObservableObject { // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() + DataStateManager.shared.updateDataState() } func problem(withId id: UUID) -> Problem? { @@ -290,6 +302,7 @@ class ClimbingDataManager: ObservableObject { saveActiveSession() saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session started successfully" clearMessageAfterDelay() @@ -317,9 +330,13 @@ class ClimbingDataManager: ObservableObject { saveActiveSession() saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session completed successfully" clearMessageAfterDelay() + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) + // MARK: - End Live Activity after session ends Task { await LiveActivityManager.shared.endLiveActivity() @@ -337,6 +354,7 @@ class ClimbingDataManager: ObservableObject { } saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session updated successfully" clearMessageAfterDelay() @@ -359,6 +377,7 @@ class ClimbingDataManager: ObservableObject { // Delete the session sessions.removeAll { $0.id == session.id } saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session deleted successfully" clearMessageAfterDelay() } @@ -380,8 +399,12 @@ class ClimbingDataManager: ObservableObject { func addAttempt(_ attempt: Attempt) { attempts.append(attempt) saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt logged successfully" + + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) clearMessageAfterDelay() // Update Live Activity when new attempt is added @@ -392,6 +415,7 @@ class ClimbingDataManager: ObservableObject { if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index] = attempt saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt updated successfully" clearMessageAfterDelay() @@ -403,6 +427,7 @@ class ClimbingDataManager: ObservableObject { func deleteAttempt(_ attempt: Attempt) { attempts.removeAll { $0.id == attempt.id } saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt deleted successfully" clearMessageAfterDelay() @@ -464,6 +489,7 @@ class ClimbingDataManager: ObservableObject { userDefaults.removeObject(forKey: Keys.attempts) userDefaults.removeObject(forKey: Keys.activeSession) + DataStateManager.shared.reset() successMessage = "All data has been reset" clearMessageAfterDelay() } @@ -473,13 +499,14 @@ class ClimbingDataManager: ObservableObject { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - let exportData = ClimbDataExport( + let exportData = ClimbDataBackup( exportedAt: dateFormatter.string(from: Date()), version: "2.0", - gyms: gyms.map { AndroidGym(from: $0) }, - problems: problems.map { AndroidProblem(from: $0) }, - sessions: sessions.map { AndroidClimbSession(from: $0) }, - attempts: attempts.map { AndroidAttempt(from: $0) } + formatVersion: "2.0", + gyms: gyms.map { BackupGym(from: $0) }, + problems: problems.map { BackupProblem(from: $0) }, + sessions: sessions.map { BackupClimbSession(from: $0) }, + attempts: attempts.map { BackupAttempt(from: $0) } ) // Collect referenced image paths @@ -529,7 +556,7 @@ class ClimbingDataManager: ObservableObject { print("Raw JSON content preview:") print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") - let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData) + let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) print("Successfully decoded import data:") print("- Gyms: \(importData.gyms.count)") @@ -546,16 +573,19 @@ class ClimbingDataManager: ObservableObject { imagePathMapping: importResult.imagePathMapping ) - self.gyms = importData.gyms.map { $0.toGym() } - self.problems = updatedProblems.map { $0.toProblem() } - self.sessions = importData.sessions.map { $0.toClimbSession() } - self.attempts = importData.attempts.map { $0.toAttempt() } + self.gyms = try importData.gyms.map { try $0.toGym() } + self.problems = try updatedProblems.map { try $0.toProblem() } + self.sessions = try importData.sessions.map { try $0.toClimbSession() } + self.attempts = try importData.attempts.map { try $0.toAttempt() } saveGyms() saveProblems() saveSessions() saveAttempts() + // Update data state to current time since we just imported new data + DataStateManager.shared.updateDataState() + successMessage = "Data imported successfully with \(importResult.imagePathMapping.count) images" clearMessageAfterDelay() @@ -584,337 +614,6 @@ class ClimbingDataManager: ObservableObject { } } -struct ClimbDataExport: Codable { - let exportedAt: String - let version: String - let gyms: [AndroidGym] - let problems: [AndroidProblem] - let sessions: [AndroidClimbSession] - let attempts: [AndroidAttempt] - - init( - exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem], - sessions: [AndroidClimbSession], attempts: [AndroidAttempt] - ) { - self.exportedAt = exportedAt - self.version = version - self.gyms = gyms - self.problems = problems - self.sessions = sessions - self.attempts = attempts - } -} - -struct AndroidGym: Codable { - let id: String - let name: String - let location: String? - let supportedClimbTypes: [ClimbType] - let difficultySystems: [DifficultySystem] - let customDifficultyGrades: [String] - let notes: String? - let createdAt: String - let updatedAt: String - - init(from gym: Gym) { - self.id = gym.id.uuidString - self.name = gym.name - self.location = gym.location - self.supportedClimbTypes = gym.supportedClimbTypes - self.difficultySystems = gym.difficultySystems - self.customDifficultyGrades = gym.customDifficultyGrades - self.notes = gym.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.createdAt = formatter.string(from: gym.createdAt) - self.updatedAt = formatter.string(from: gym.updatedAt) - } - - init( - id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], - difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [], - notes: String?, createdAt: String, updatedAt: String - ) { - self.id = id - self.name = name - self.location = location - self.supportedClimbTypes = supportedClimbTypes - self.difficultySystems = difficultySystems - self.customDifficultyGrades = customDifficultyGrades - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toGym() -> Gym { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let gymId = UUID(uuidString: id) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Gym.fromImport( - id: gymId, - name: name, - location: location, - supportedClimbTypes: supportedClimbTypes, - difficultySystems: difficultySystems, - customDifficultyGrades: customDifficultyGrades, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidProblem: Codable { - let id: String - let gymId: String - let name: String? - let description: String? - let climbType: ClimbType - let difficulty: DifficultyGrade - let tags: [String] - let location: String? - let imagePaths: [String]? - let isActive: Bool - let dateSet: String? - let notes: String? - let createdAt: String - let updatedAt: String - - init(from problem: Problem) { - self.id = problem.id.uuidString - self.gymId = problem.gymId.uuidString - self.name = problem.name - self.description = problem.description - self.climbType = problem.climbType - self.difficulty = problem.difficulty - self.tags = problem.tags - self.location = problem.location - self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths - self.isActive = problem.isActive - self.notes = problem.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil - self.createdAt = formatter.string(from: problem.createdAt) - self.updatedAt = formatter.string(from: problem.updatedAt) - } - - init( - id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, tags: [String] = [], - location: String? = nil, - imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil, - notes: String? = nil, - createdAt: String, updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.name = name - self.description = description - self.climbType = climbType - self.difficulty = difficulty - self.tags = tags - self.location = location - self.imagePaths = imagePaths - self.isActive = isActive - self.dateSet = dateSet - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toProblem() -> Problem { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let problemId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Problem.fromImport( - id: problemId, - gymId: preservedGymId, - name: name, - description: description, - climbType: climbType, - difficulty: difficulty, - tags: tags, - location: location, - imagePaths: imagePaths ?? [], - isActive: isActive, - dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } - - func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem { - return AndroidProblem( - id: self.id, - gymId: self.gymId, - name: self.name, - description: self.description, - climbType: self.climbType, - difficulty: self.difficulty, - tags: self.tags, - location: self.location, - imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, - isActive: self.isActive, - dateSet: self.dateSet, - notes: self.notes, - createdAt: self.createdAt, - updatedAt: self.updatedAt - ) - } -} - -struct AndroidClimbSession: Codable { - let id: String - let gymId: String - let date: String - let startTime: String? - let endTime: String? - let duration: Int64? - let status: SessionStatus - let notes: String? - let createdAt: String - let updatedAt: String - - init(from session: ClimbSession) { - self.id = session.id.uuidString - self.gymId = session.gymId.uuidString - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.date = formatter.string(from: session.date) - self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil - self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil - self.duration = session.duration != nil ? Int64(session.duration!) : nil - self.status = session.status - self.notes = session.notes - self.createdAt = formatter.string(from: session.createdAt) - self.updatedAt = formatter.string(from: session.updatedAt) - } - - init( - id: String, gymId: String, date: String, startTime: String?, endTime: String?, - duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String, - updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.date = date - self.startTime = startTime - self.endTime = endTime - self.duration = duration - self.status = status - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toClimbSession() -> ClimbSession { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - // Preserve original IDs and dates - let sessionId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let sessionDate = formatter.date(from: date) ?? Date() - let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil - let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return ClimbSession.fromImport( - id: sessionId, - gymId: preservedGymId, - date: sessionDate, - startTime: sessionStartTime, - endTime: sessionEndTime, - duration: duration != nil ? Int(duration!) : nil, - status: status, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidAttempt: Codable { - let id: String - let sessionId: String - let problemId: String - let result: AttemptResult - let highestHold: String? - let notes: String? - let duration: Int64? - let restTime: Int64? - let timestamp: String - let createdAt: String - - init(from attempt: Attempt) { - self.id = attempt.id.uuidString - self.sessionId = attempt.sessionId.uuidString - self.problemId = attempt.problemId.uuidString - self.result = attempt.result - self.highestHold = attempt.highestHold - self.notes = attempt.notes - self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil - self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.timestamp = formatter.string(from: attempt.timestamp) - self.createdAt = formatter.string(from: attempt.createdAt) - } - - init( - id: String, sessionId: String, problemId: String, result: AttemptResult, - highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?, - timestamp: String, createdAt: String - ) { - self.id = id - self.sessionId = sessionId - self.problemId = problemId - self.result = result - self.highestHold = highestHold - self.notes = notes - self.duration = duration - self.restTime = restTime - self.timestamp = timestamp - self.createdAt = createdAt - } - - func toAttempt() -> Attempt { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let attemptId = UUID(uuidString: id) ?? UUID() - let preservedSessionId = UUID(uuidString: sessionId) ?? UUID() - let preservedProblemId = UUID(uuidString: problemId) ?? UUID() - let attemptTimestamp = formatter.date(from: timestamp) ?? Date() - let createdDate = formatter.date(from: createdAt) ?? Date() - - return Attempt.fromImport( - id: attemptId, - sessionId: preservedSessionId, - problemId: preservedProblemId, - result: result, - highestHold: highestHold, - notes: notes, - duration: duration != nil ? Int(duration!) : nil, - restTime: restTime != nil ? Int(restTime!) : nil, - timestamp: attemptTimestamp, - createdAt: createdDate - ) - } -} - extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -949,9 +648,9 @@ extension ClimbingDataManager { } private func updateProblemImagePaths( - problems: [AndroidProblem], + problems: [BackupProblem], imagePathMapping: [String: String] - ) -> [AndroidProblem] { + ) -> [BackupProblem] { return problems.map { problem in let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in let fileName = URL(fileURLWithPath: oldPath).lastPathComponent @@ -1298,7 +997,7 @@ extension ClimbingDataManager { saveAttempts() } - private func validateImportData(_ importData: ClimbDataExport) throws { + private func validateImportData(_ importData: ClimbDataBackup) throws { if importData.gyms.isEmpty { throw NSError( domain: "ImportError", code: 1, diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index c6e7992..4f68042 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -130,14 +130,8 @@ final class LiveActivityManager { completedProblems: completedProblems ) - do { - await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("✅ Live Activity updated successfully") - } catch { - print("❌ Failed to update Live Activity: \(error)") - // If update fails, the activity might have been dismissed - self.currentActivity = nil - } + await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) + print("✅ Live Activity updated successfully") } /// Call this when a ClimbSession ends to end the Live Activity diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index a9df99b..0d759d3 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -168,15 +168,9 @@ struct ActiveSessionBanner: View { .onDisappear { stopTimer() } - .background( - NavigationLink( - destination: SessionDetailView(sessionId: session.id), - isActive: $navigateToDetail - ) { - EmptyView() - } - .hidden() - ) + .navigationDestination(isPresented: $navigateToDetail) { + SessionDetailView(sessionId: session.id) + } } private func formatDuration(from start: Date, to end: Date) -> String { diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 79a832e..56a2694 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -12,6 +12,9 @@ struct SettingsView: View { var body: some View { List { + SyncSection() + .environmentObject(dataManager.syncService) + DataManagementSection( activeSheet: $activeSheet ) @@ -303,6 +306,361 @@ struct ExportDataView: View { } } +struct SyncSection: View { + @EnvironmentObject var syncService: SyncService + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingSyncSettings = false + @State private var showingDisconnectAlert = false + + var body: some View { + Section("Sync") { + // Sync Status + HStack { + Image( + systemName: syncService.isConnected + ? "checkmark.circle.fill" + : syncService.isConfigured + ? "exclamationmark.triangle.fill" + : "exclamationmark.circle.fill" + ) + .foregroundColor( + syncService.isConnected + ? .green + : syncService.isConfigured + ? .orange + : .red + ) + VStack(alignment: .leading) { + Text("Sync Server") + .font(.headline) + Text( + syncService.isConnected + ? "Connected" + : syncService.isConfigured + ? "Configured - Not tested" + : "Not configured" + ) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + + // Configure Server + Button(action: { + showingSyncSettings = true + }) { + HStack { + Image(systemName: "gear") + .foregroundColor(.blue) + Text("Configure Server") + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + + if syncService.isConfigured { + + // Sync Now - only show if connected + if syncService.isConnected { + Button(action: { + performSync() + }) { + HStack { + if syncService.isSyncing { + ProgressView() + .scaleEffect(0.8) + Text("Syncing...") + .foregroundColor(.secondary) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundColor(.green) + Text("Sync Now") + Spacer() + if let lastSync = syncService.lastSyncTime { + Text( + RelativeDateTimeFormatter().localizedString( + for: lastSync, relativeTo: Date()) + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .disabled(syncService.isSyncing) + .foregroundColor(.primary) + } + + // Auto-sync configuration - always visible for testing + HStack { + VStack(alignment: .leading) { + Text("Auto-sync") + Text("Sync automatically on app launch and data changes") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { syncService.isAutoSyncEnabled }, + set: { syncService.isAutoSyncEnabled = $0 } + ) + ) + .disabled(!syncService.isConnected) + } + .foregroundColor(.primary) + + // Disconnect option - only show if connected + if syncService.isConnected { + Button(action: { + showingDisconnectAlert = true + }) { + HStack { + Image(systemName: "power") + .foregroundColor(.orange) + Text("Disconnect") + Spacer() + } + } + .foregroundColor(.primary) + } + + if let error = syncService.syncError { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding(.leading, 24) + } + } + } + .sheet(isPresented: $showingSyncSettings) { + SyncSettingsView() + .environmentObject(syncService) + } + .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { + Button("Cancel", role: .cancel) {} + Button("Disconnect", role: .destructive) { + syncService.disconnect() + } + } message: { + Text( + "This will sign you out but keep your server settings. You'll need to test the connection again to sync." + ) + } + } + + private func performSync() { + Task { + do { + try await syncService.syncWithServer(dataManager: dataManager) + } catch { + print("Sync failed: \(error)") + } + } + } +} + +struct SyncSettingsView: View { + @EnvironmentObject var syncService: SyncService + @Environment(\.dismiss) private var dismiss + @State private var serverURL: String = "" + @State private var authToken: String = "" + @State private var showingDisconnectAlert = false + @State private var isTesting = false + @State private var showingTestResult = false + @State private var testResultMessage = "" + + var body: some View { + NavigationView { + Form { + Section { + TextField("Server URL", text: $serverURL) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .placeholder(when: serverURL.isEmpty) { + Text("http://your-server:8080") + .foregroundColor(.secondary) + } + + TextField("Auth Token", text: $authToken) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .placeholder(when: authToken.isEmpty) { + Text("your-secret-token") + .foregroundColor(.secondary) + } + } header: { + Text("Server Configuration") + } footer: { + Text( + "Enter your sync server URL and authentication token. You must test the connection before syncing is available." + ) + } + + Section { + Button(action: { + testConnection() + }) { + HStack { + if isTesting { + ProgressView() + .scaleEffect(0.8) + Text("Testing...") + .foregroundColor(.secondary) + } else { + Image(systemName: "network") + .foregroundColor(.blue) + Text("Test Connection") + Spacer() + if syncService.isConnected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + } + } + .disabled( + isTesting + || serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + .foregroundColor(.primary) + } header: { + Text("Connection") + } footer: { + Text("Test the connection to verify your server settings before saving.") + } + + Section { + Button("Disconnect from Server") { + showingDisconnectAlert = true + } + .foregroundColor(.orange) + + Button("Clear Configuration") { + syncService.clearConfiguration() + serverURL = "" + authToken = "" + } + .foregroundColor(.red) + } footer: { + Text( + "Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings." + ) + } + } + .navigationTitle("Sync Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) + + // Mark as disconnected if settings changed + if newURL != syncService.serverURL || newToken != syncService.authToken { + syncService.isConnected = false + UserDefaults.standard.set(false, forKey: "sync_is_connected") + } + + syncService.serverURL = newURL + syncService.authToken = newToken + dismiss() + } + .fontWeight(.semibold) + } + } + } + .onAppear { + serverURL = syncService.serverURL + authToken = syncService.authToken + } + .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { + Button("Cancel", role: .cancel) {} + Button("Disconnect", role: .destructive) { + syncService.disconnect() + dismiss() + } + } message: { + Text( + "This will sign you out but keep your server settings. You'll need to test the connection again to sync." + ) + } + .alert("Connection Test", isPresented: $showingTestResult) { + Button("OK") {} + } message: { + Text(testResultMessage) + } + } + + private func testConnection() { + isTesting = true + + let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) + + // Store original values in case test fails + let originalURL = syncService.serverURL + let originalToken = syncService.authToken + + Task { + do { + // Temporarily set the values for testing + syncService.serverURL = testURL + syncService.authToken = testToken + + try await syncService.testConnection() + + await MainActor.run { + isTesting = false + testResultMessage = + "Connection successful! You can now save and sync your data." + showingTestResult = true + } + } catch { + // Restore original values if test failed + syncService.serverURL = originalURL + syncService.authToken = originalToken + + await MainActor.run { + isTesting = false + testResultMessage = "Connection failed: \(error.localizedDescription)" + showingTestResult = true + } + } + } + } +} + +// Removed AutoSyncSettingsView - now using simple toggle in main settings + +extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content + ) -> some View { + + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} + struct ImportDataView: View { @EnvironmentObject var dataManager: ClimbingDataManager @Environment(\.dismiss) private var dismiss diff --git a/sync/.env.example b/sync/.env.example new file mode 100644 index 0000000..bc6aa89 --- /dev/null +++ b/sync/.env.example @@ -0,0 +1,14 @@ +# OpenClimb Sync Server Configuration + +# Required: Secret token for authentication +# Generate a secure random token and share it between your apps and server +AUTH_TOKEN=your-secure-secret-token-here + +# Optional: Port to run the server on (default: 8080) +PORT=8080 + +# Optional: Path to store the sync data (default: ./data/climb_data.json) +DATA_FILE=./data/climb_data.json + +# Optional: Directory to store images (default: ./data/images) +IMAGES_DIR=./data/images diff --git a/sync/.gitignore b/sync/.gitignore new file mode 100644 index 0000000..9fc8fde --- /dev/null +++ b/sync/.gitignore @@ -0,0 +1,16 @@ +# Binaries +sync-server +openclimb-sync + +# Go workspace file +go.work + +# Data directory +data/ + +# Environment files +.env +.env.local + +# OS generated files +.DS_Store diff --git a/sync/Dockerfile b/sync/Dockerfile new file mode 100644 index 0000000..25442c0 --- /dev/null +++ b/sync/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o sync-server . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ + +COPY --from=builder /app/sync-server . + +EXPOSE 8080 +CMD ["./sync-server"] diff --git a/sync/docker-compose.yml b/sync/docker-compose.yml new file mode 100644 index 0000000..ca7977d --- /dev/null +++ b/sync/docker-compose.yml @@ -0,0 +1,12 @@ +services: + openclimb-sync: + image: ${IMAGE} + ports: + - "8080:8080" + environment: + - AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here} + - DATA_FILE=/data/climb_data.json + - IMAGES_DIR=/data/images + volumes: + - ./data:/data + restart: unless-stopped diff --git a/sync/go.mod b/sync/go.mod new file mode 100644 index 0000000..3103696 --- /dev/null +++ b/sync/go.mod @@ -0,0 +1,3 @@ +module openclimb-sync + +go 1.25 diff --git a/sync/main.go b/sync/main.go new file mode 100644 index 0000000..7034a3f --- /dev/null +++ b/sync/main.go @@ -0,0 +1,358 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type ClimbDataBackup struct { + ExportedAt string `json:"exportedAt"` + Version string `json:"version"` + FormatVersion string `json:"formatVersion"` + Gyms []BackupGym `json:"gyms"` + Problems []BackupProblem `json:"problems"` + Sessions []BackupClimbSession `json:"sessions"` + Attempts []BackupAttempt `json:"attempts"` +} + +type BackupGym struct { + ID string `json:"id"` + Name string `json:"name"` + Location *string `json:"location,omitempty"` + SupportedClimbTypes []string `json:"supportedClimbTypes"` + DifficultySystems []string `json:"difficultySystems"` + CustomDifficultyGrades []string `json:"customDifficultyGrades"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type BackupProblem struct { + ID string `json:"id"` + GymID string `json:"gymId"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + ClimbType string `json:"climbType"` + Difficulty DifficultyGrade `json:"difficulty"` + Tags []string `json:"tags"` + Location *string `json:"location,omitempty"` + ImagePaths []string `json:"imagePaths,omitempty"` + IsActive bool `json:"isActive"` + DateSet *string `json:"dateSet,omitempty"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type DifficultyGrade struct { + System string `json:"system"` + Grade string `json:"grade"` + NumericValue int `json:"numericValue"` +} + +type BackupClimbSession struct { + ID string `json:"id"` + GymID string `json:"gymId"` + Date string `json:"date"` + StartTime *string `json:"startTime,omitempty"` + EndTime *string `json:"endTime,omitempty"` + Duration *int64 `json:"duration,omitempty"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type BackupAttempt struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + ProblemID string `json:"problemId"` + Result string `json:"result"` + HighestHold *string `json:"highestHold,omitempty"` + Notes *string `json:"notes,omitempty"` + Duration *int64 `json:"duration,omitempty"` + RestTime *int64 `json:"restTime,omitempty"` + Timestamp string `json:"timestamp"` + CreatedAt string `json:"createdAt"` +} + +type SyncServer struct { + authToken string + dataFile string + imagesDir string +} + +func (s *SyncServer) authenticate(r *http.Request) bool { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return false + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + return subtle.ConstantTimeCompare([]byte(token), []byte(s.authToken)) == 1 +} + +func (s *SyncServer) loadData() (*ClimbDataBackup, error) { + log.Printf("Loading data from: %s", s.dataFile) + + if _, err := os.Stat(s.dataFile); os.IsNotExist(err) { + log.Printf("Data file does not exist, creating empty backup") + return &ClimbDataBackup{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, nil + } + + data, err := os.ReadFile(s.dataFile) + if err != nil { + log.Printf("Failed to read data file: %v", err) + return nil, err + } + + log.Printf("Read %d bytes from data file", len(data)) + log.Printf("File content preview: %s", string(data[:min(200, len(data))])) + + var backup ClimbDataBackup + if err := json.Unmarshal(data, &backup); err != nil { + log.Printf("Failed to unmarshal JSON: %v", err) + return nil, err + } + + log.Printf("Loaded backup: gyms=%d, problems=%d, sessions=%d, attempts=%d", + len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) + + return &backup, nil +} + +func (s *SyncServer) saveData(backup *ClimbDataBackup) error { + backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return err + } + + dir := filepath.Dir(s.dataFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Ensure images directory exists + if err := os.MkdirAll(s.imagesDir, 0755); err != nil { + return err + } + + return os.WriteFile(s.dataFile, data, 0644) +} + +func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized access attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + log.Printf("GET /sync request from %s", r.RemoteAddr) + backup, err := s.loadData() + if err != nil { + log.Printf("Failed to load data: %v", err) + http.Error(w, "Failed to load data", http.StatusInternalServerError) + return + } + + log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", + r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(backup) +} + +func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized sync attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var backup ClimbDataBackup + if err := json.NewDecoder(r.Body).Decode(&backup); err != nil { + log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err) + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if err := s.saveData(&backup); err != nil { + log.Printf("Failed to save data: %v", err) + http.Error(w, "Failed to save data", http.StatusInternalServerError) + return + } + + log.Printf("Data synced by %s", r.RemoteAddr) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(backup) +} + +func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *SyncServer) handleImageUpload(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized image upload attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Missing filename parameter", http.StatusBadRequest) + return + } + + imageData, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read image data", http.StatusBadRequest) + return + } + + imagePath := filepath.Join(s.imagesDir, filename) + if err := os.WriteFile(imagePath, imageData, 0644); err != nil { + log.Printf("Failed to save image %s: %v", filename, err) + http.Error(w, "Failed to save image", http.StatusInternalServerError) + return + } + + log.Printf("Image uploaded: %s (%d bytes) by %s", filename, len(imageData), r.RemoteAddr) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "uploaded"}) +} + +func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized image download attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Missing filename parameter", http.StatusBadRequest) + return + } + + imagePath := filepath.Join(s.imagesDir, filename) + imageData, err := os.ReadFile(imagePath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "Image not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to read image", http.StatusInternalServerError) + } + return + } + + // Set appropriate content type based on file extension + ext := filepath.Ext(filename) + switch ext { + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".gif": + w.Header().Set("Content-Type", "image/gif") + case ".webp": + w.Header().Set("Content-Type", "image/webp") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + + w.WriteHeader(http.StatusOK) + w.Write(imageData) +} + +func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleGet(w, r) + case http.MethodPut: + s.handlePut(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func main() { + authToken := os.Getenv("AUTH_TOKEN") + if authToken == "" { + log.Fatal("AUTH_TOKEN environment variable is required") + } + + dataFile := os.Getenv("DATA_FILE") + if dataFile == "" { + dataFile = "./data/climb_data.json" + } + + imagesDir := os.Getenv("IMAGES_DIR") + if imagesDir == "" { + imagesDir = "./data/images" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + server := &SyncServer{ + authToken: authToken, + dataFile: dataFile, + imagesDir: imagesDir, + } + + http.HandleFunc("/sync", server.handleSync) + http.HandleFunc("/health", server.handleHealth) + http.HandleFunc("/images/upload", server.handleImageUpload) + http.HandleFunc("/images/download", server.handleImageDownload) + + fmt.Printf("OpenClimb sync server starting on port %s\n", port) + fmt.Printf("Data file: %s\n", dataFile) + fmt.Printf("Images directory: %s\n", imagesDir) + fmt.Printf("Health check available at /health\n") + fmt.Printf("Image upload: POST /images/upload?filename=\n") + fmt.Printf("Image download: GET /images/download?filename=\n") + + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/sync/version.md b/sync/version.md new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/sync/version.md @@ -0,0 +1 @@ +1.0.0