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/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive b/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive
deleted file mode 100644
index e69de29..0000000
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index dc0c78f..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 = 27
- versionName = "1.6.0"
+ 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
index e1658a7..f8fae25 100644
--- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt
@@ -23,6 +23,7 @@ data class BackupGym(
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
@@ -91,7 +92,13 @@ data class BackupProblem(
difficulty = problem.difficulty,
tags = problem.tags,
location = problem.location,
- imagePaths = problem.imagePaths.ifEmpty { null },
+ 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,
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 37acff7..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
@@ -8,9 +8,10 @@ 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
@@ -20,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
@@ -29,17 +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)
+ 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)
+ 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()
@@ -48,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()) {
@@ -66,9 +107,21 @@ 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)
+ 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 {
@@ -84,7 +137,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
- exportedAt = LocalDateTime.now().toString(),
+ exportedAt = DateFormatUtils.nowISO8601(),
version = "2.0",
formatVersion = "2.0",
gyms = allGyms.map { BackupGym.fromGym(it) },
@@ -154,7 +207,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
- // Import gyms first (problems depend on gyms)
+ // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
+ // state updates
importData.gyms.forEach { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
@@ -170,7 +224,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
importResult.importedImagePaths
)
- // Import problems (depends on gyms)
+ // Import problems (depends on gyms) - use DAO directly
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
@@ -181,7 +235,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
- // Import sessions
+ // Import sessions - use DAO directly
importData.sessions.forEach { backupSession ->
try {
sessionDao.insertSession(backupSession.toClimbSession())
@@ -190,7 +244,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
- // Import attempts last (depends on problems and sessions)
+ // Import attempts last (depends on problems and sessions) - use DAO directly
importData.attempts.forEach { backupAttempt ->
try {
attemptDao.insertAttempt(backupAttempt.toAttempt())
@@ -198,11 +252,30 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
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,
@@ -260,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()
@@ -268,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
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/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
new file mode 100644
index 0000000..178a980
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.jar
@@ -0,0 +1,16 @@
+
+404 Not Found
+
+404 Not Found
+
nginx
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj
index 0a60cb7..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 = 10;
+ 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.1.0;
+ 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 = 10;
+ 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.1.0;
+ 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 = 10;
+ 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.1.0;
+ 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 = 10;
+ 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.1.0;
+ 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 2f1b85e..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 ea9c62f..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()
@@ -101,6 +103,8 @@ struct ContentView: View {
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
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
index 1a0f95a..a0dcb3c 100644
--- a/ios/OpenClimb/Models/BackupFormat.swift
+++ b/ios/OpenClimb/Models/BackupFormat.swift
@@ -1,10 +1,5 @@
//
// BackupFormat.swift
-// OpenClimb
-//
-// Created by OpenClimb Team on 2024-12-19.
-// Copyright © 2024 OpenClimb. All rights reserved.
-//
import Foundation
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/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
index 24719ec..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()
}
@@ -557,6 +583,9 @@ class ClimbingDataManager: ObservableObject {
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()
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-server/DEPLOY.md b/sync-server/DEPLOY.md
new file mode 100644
index 0000000..b34440d
--- /dev/null
+++ b/sync-server/DEPLOY.md
@@ -0,0 +1,303 @@
+# OpenClimb Sync Server Deployment Guide
+
+This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system.
+
+## Overview
+
+The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment.
+
+## Prerequisites
+
+- Docker and Docker Compose installed
+- Access to the container registry (configured in GitHub secrets)
+- Basic understanding of Docker deployments
+
+## Quick Start
+
+### 1. Automated Deployment (Recommended)
+
+```bash
+# Clone the repository
+git clone
+cd OpenClimb/sync-server
+
+# Run the deployment script
+./deploy.sh
+```
+
+The script will:
+- Create necessary directories
+- Pull the latest container image
+- Stop any existing containers
+- Start the new container
+- Verify deployment success
+
+### 2. Manual Deployment
+
+```bash
+# Pull the latest image
+docker pull your-registry.com/username/openclimb-sync-server:latest
+
+# Create environment file
+cp .env.example .env.prod
+# Edit .env.prod with your configuration
+
+# Deploy with docker-compose
+docker-compose -f docker-compose.prod.yml up -d
+```
+
+## Configuration
+
+### Environment Variables
+
+Create a `.env.prod` file with the following variables:
+
+```bash
+# Container registry settings
+REPO_HOST=your-registry.example.com
+REPO_OWNER=your-username
+
+# Server configuration
+AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random
+PORT=8080
+
+# Optional: Custom domain (for Traefik)
+TRAEFIK_HOST=sync.openclimb.example.com
+```
+
+### Required Secrets (GitHub)
+
+Configure these secrets in your GitHub repository settings:
+
+- `REPO_HOST`: Your container registry hostname
+- `DEPLOY_TOKEN`: Authentication token for the registry
+
+## Container Build Process
+
+The GitHub Action (`sync-server-deploy.yml`) automatically:
+
+1. **Triggers on:**
+ - Push to `main` branch (when sync-server files change)
+ - Pull requests to `main` branch
+
+2. **Build Process:**
+ - Uses multi-stage Docker build
+ - Compiles Go binary in builder stage
+ - Creates minimal Alpine-based runtime image
+ - Pushes to container registry with tags:
+ - `latest` (always points to newest)
+ - `` (specific version)
+
+3. **Caching:**
+ - Uses GitHub Actions cache for faster builds
+ - Incremental builds when possible
+
+## Deployment Options
+
+### Option 1: Simple Docker Run
+```bash
+docker run -d \
+ --name openclimb-sync-server \
+ -p 8080:8080 \
+ -v $(pwd)/data:/root/data \
+ -e AUTH_TOKEN=your-token-here \
+ your-registry.com/username/openclimb-sync-server:latest
+```
+
+### Option 2: Docker Compose (Recommended)
+```bash
+docker-compose -f docker-compose.prod.yml up -d
+```
+
+### Option 3: Kubernetes
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: openclimb-sync-server
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: openclimb-sync-server
+ template:
+ metadata:
+ labels:
+ app: openclimb-sync-server
+ spec:
+ containers:
+ - name: sync-server
+ image: your-registry.com/username/openclimb-sync-server:latest
+ ports:
+ - containerPort: 8080
+ env:
+ - name: AUTH_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: openclimb-secrets
+ key: auth-token
+ volumeMounts:
+ - name: data-volume
+ mountPath: /root/data
+ volumes:
+ - name: data-volume
+ persistentVolumeClaim:
+ claimName: openclimb-data
+```
+
+## Data Persistence
+
+The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data:
+
+```bash
+# Local directory mounting
+-v $(pwd)/data:/root/data
+
+# Named volume (recommended for production)
+-v openclimb-data:/root/data
+```
+
+### Data Structure
+```
+data/
+├── climb_data.json # Main sync data
+├── images/ # Uploaded images
+│ ├── problem_*.jpg
+│ └── ...
+└── logs/ # Server logs (optional)
+```
+
+## Monitoring and Maintenance
+
+### Health Check
+```bash
+curl http://localhost:8080/health
+```
+
+### View Logs
+```bash
+# Docker Compose
+docker-compose -f docker-compose.prod.yml logs -f
+
+# Direct Docker
+docker logs -f openclimb-sync-server
+```
+
+### Update to Latest Version
+```bash
+# Using deploy script
+./deploy.sh
+
+# Manual update
+docker-compose -f docker-compose.prod.yml pull
+docker-compose -f docker-compose.prod.yml up -d
+```
+
+## Reverse Proxy Setup (Optional)
+
+### Nginx
+```nginx
+server {
+ listen 80;
+ server_name sync.openclimb.example.com;
+
+ location / {
+ proxy_pass http://localhost:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+### Traefik (Labels included in docker-compose.prod.yml)
+```yaml
+labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)"
+ - "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt"
+```
+
+## Security Considerations
+
+1. **AUTH_TOKEN**: Use a long, random token (32+ characters)
+2. **HTTPS**: Always use HTTPS in production (via reverse proxy)
+3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly
+4. **Updates**: Regularly update to the latest container image
+5. **Backups**: Regularly backup the `data/` directory
+
+## Troubleshooting
+
+### Container Won't Start
+```bash
+# Check logs
+docker logs openclimb-sync-server
+
+# Common issues:
+# - Missing AUTH_TOKEN environment variable
+# - Port 8080 already in use
+# - Insufficient permissions on data directory
+```
+
+### Sync Fails from Mobile Apps
+```bash
+# Verify server is accessible
+curl -H "Authorization: Bearer your-token" http://your-server:8080/sync
+
+# Check server logs for authentication errors
+docker logs openclimb-sync-server | grep "401\|403"
+```
+
+### Image Upload Issues
+```bash
+# Check disk space
+df -h
+
+# Verify data directory permissions
+ls -la data/
+```
+
+## Performance Tuning
+
+For high-load deployments:
+
+```yaml
+# docker-compose.prod.yml
+services:
+ openclimb-sync-server:
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: '0.5'
+ reservations:
+ memory: 256M
+ cpus: '0.25'
+```
+
+## Backup Strategy
+
+```bash
+#!/bin/bash
+# backup.sh - Run daily via cron
+
+DATE=$(date +%Y%m%d_%H%M%S)
+BACKUP_DIR="/backups/openclimb"
+
+# Create backup directory
+mkdir -p "$BACKUP_DIR"
+
+# Backup data directory
+tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \
+ -C /path/to/sync-server data/
+
+# Keep only last 30 days
+find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete
+```
+
+## Support
+
+- **Issues**: Create an issue in the GitHub repository
+- **Documentation**: Check the main OpenClimb README
+- **Logs**: Always
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/run.sh b/sync/run.sh
new file mode 100755
index 0000000..76a3382
--- /dev/null
+++ b/sync/run.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# OpenClimb Sync Server Runner
+set -e
+
+# Default values
+AUTH_TOKEN=${AUTH_TOKEN:-}
+PORT=${PORT:-8080}
+DATA_FILE=${DATA_FILE:-./data/climb_data.json}
+
+# Check if AUTH_TOKEN is set
+if [ -z "$AUTH_TOKEN" ]; then
+ echo "Error: AUTH_TOKEN environment variable must be set"
+ echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh"
+ echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh"
+ exit 1
+fi
+
+# Create data directory if it doesn't exist
+mkdir -p "$(dirname "$DATA_FILE")"
+
+# Build and run
+echo "Building OpenClimb sync server..."
+go build -o sync-server .
+
+echo "Starting server on port $PORT"
+echo "Data will be stored in: $DATA_FILE"
+echo "Images will be stored in: ${IMAGES_DIR:-./data/images}"
+echo "Use Authorization: Bearer $AUTH_TOKEN in your requests"
+echo ""
+
+exec ./sync-server
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