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 e708b1c..178a980 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
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 6acfc54..7fb5b30 100644
Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ
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