diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..1944898
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,22 @@
+# OpenClimb for Android
+
+This is the native Android app for OpenClimb, built with Kotlin and Jetpack Compose.
+
+## Project Structure
+
+This is a standard Android Gradle project. The main code lives in `app/src/main/java/com/atridad/openclimb/`.
+
+- `data/`: Handles all the app's data.
+ - `database/`: Room database setup (DAOs, entities).
+ - `model/`: Core data models (`Problem`, `Gym`, `ClimbSession`).
+ - `repository/`: Manages the data, providing a clean API for the rest of the app.
+ - `sync/`: Handles talking to the sync server.
+- `ui/`: All the Jetpack Compose UI code.
+ - `screens/`: The main screens of the app.
+ - `components/`: Reusable UI bits used across screens.
+ - `viewmodel/`: `ClimbViewModel` for managing UI state.
+- `navigation/`: Navigation graph and routes using Jetpack Navigation.
+- `service/`: Background service for tracking climbing sessions.
+- `utils/`: Helpers for things like date formatting and image handling.
+
+The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 8dd927e..acecb41 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 = 38
- versionName = "1.9.1"
+ versionCode = 39
+ versionName = "1.9.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d5e5d51..52b0e91 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
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
deleted file mode 100644
index 6b0d619..0000000
--- a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-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 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 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 2ebda6d..92d4e57 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
@@ -75,25 +75,4 @@ data class Attempt(
}
}
- fun updated(
- result: AttemptResult? = null,
- highestHold: String? = null,
- notes: String? = null,
- duration: Long? = null,
- restTime: Long? = null
- ): Attempt {
- return Attempt(
- id = this.id,
- sessionId = this.sessionId,
- problemId = this.problemId,
- result = result ?: this.result,
- highestHold = highestHold ?: this.highestHold,
- notes = notes ?: this.notes,
- duration = duration ?: this.duration,
- restTime = restTime ?: this.restTime,
- timestamp = this.timestamp,
- createdAt = this.createdAt,
- 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 33c28aa..39da548 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
@@ -207,7 +207,7 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
- if (grade1 == "VB" && grade2 == "VB") return 0
+ if (grade1 == "VB") return 0
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
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 a83318c..f028bae 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
@@ -17,8 +17,6 @@ import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
@@ -288,7 +286,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
try {
val deletion = json.decodeFromString(value)
deletions.add(deletion)
- } catch (e: Exception) {
+ } catch (_: Exception) {
// Invalid deletion record, ignore
}
}
diff --git a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt
index 01ad1db..85064c4 100644
--- a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.atridad.openclimb.utils.DateFormatUtils
+import androidx.core.content.edit
/**
* Manages the overall data state timestamp for sync purposes. This tracks when any data in the
@@ -35,7 +36,7 @@ class DataStateManager(context: Context) {
*/
fun updateDataState() {
val now = DateFormatUtils.nowISO8601()
- prefs.edit().putString(KEY_LAST_MODIFIED, now).apply()
+ prefs.edit { putString(KEY_LAST_MODIFIED, now) }
Log.d(TAG, "Data state updated to: $now")
}
@@ -48,21 +49,6 @@ class DataStateManager(context: Context) {
?: 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)
@@ -70,11 +56,7 @@ class DataStateManager(context: Context) {
/** Marks the data state as initialized. */
private fun markAsInitialized() {
- prefs.edit().putBoolean(KEY_INITIALIZED, true).apply()
+ prefs.edit { putBoolean(KEY_INITIALIZED, true) }
}
- /** 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
index 9176b5f..9aac55b 100644
--- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt
@@ -2,30 +2,28 @@ package com.atridad.openclimb.data.sync
import android.content.Context
import android.content.SharedPreferences
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
import android.util.Log
+import androidx.annotation.RequiresPermission
import androidx.core.content.edit
import com.atridad.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.format.DeletedItem
-import com.atridad.openclimb.data.migration.ImageMigrationService
-import com.atridad.openclimb.data.model.Attempt
-import com.atridad.openclimb.data.model.ClimbSession
-import com.atridad.openclimb.data.model.Gym
-import com.atridad.openclimb.data.model.Problem
-import com.atridad.openclimb.data.model.SessionStatus
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.io.Serializable
import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -43,9 +41,9 @@ 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()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
private const val TAG = "SyncService"
@@ -65,8 +63,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
- encodeDefaults = true
- coerceInputValues = true
}
// State
@@ -91,6 +87,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow = _isAutoSyncEnabled.asStateFlow()
+ private var isOfflineMode = false
+
// Debounced sync properties
private var syncJob: Job? = null
private var pendingChanges = false
@@ -98,22 +96,49 @@ class SyncService(private val context: Context, private val repository: ClimbRep
// Configuration keys
private object Keys {
- const val SERVER_URL = "sync_server_url"
- const val AUTH_TOKEN = "sync_auth_token"
+ const val SERVER_URL = "server_url"
+ const val AUTH_TOKEN = "auth_token"
+ const val IS_CONNECTED = "is_connected"
const val LAST_SYNC_TIME = "last_sync_time"
- const val IS_CONNECTED = "sync_is_connected"
const val AUTO_SYNC_ENABLED = "auto_sync_enabled"
+ const val OFFLINE_MODE = "offline_mode"
}
- // Configuration properties
- var serverURL: String
+ init {
+ loadInitialState()
+ updateConfiguredState()
+ repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } }
+ }
+
+ private fun loadInitialState() {
+ _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
+ _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
+ _isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
+ isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false)
+ }
+
+ private fun updateConfiguredState() {
+ _isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank()
+ }
+
+ var serverUrl: String
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, value) }
updateConfiguredState()
- // Clear connection status when configuration changes
_isConnected.value = false
- sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
+ sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
+ }
+
+ // Legacy accessor expected by some UI code (kept for compatibility)
+ @Deprecated(
+ message = "Use serverUrl (kebab case) instead",
+ replaceWith = ReplaceWith("serverUrl")
+ )
+ var serverURL: String
+ get() = serverUrl
+ set(value) {
+ serverUrl = value
}
var authToken: String
@@ -121,237 +146,42 @@ class SyncService(private val context: Context, private val repository: ClimbRep
set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) }
updateConfiguredState()
- // Clear connection status when configuration changes
_isConnected.value = false
sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
}
- val isConfigured: Boolean
- get() = serverURL.isNotEmpty() && authToken.isNotEmpty()
-
- private fun updateConfiguredState() {
- _isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
- }
-
fun setAutoSyncEnabled(enabled: Boolean) {
- sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, enabled).apply()
_isAutoSyncEnabled.value = enabled
+ sharedPreferences.edit { putBoolean(Keys.AUTO_SYNC_ENABLED, enabled) }
}
- init {
- _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
- _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
- _isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
- updateConfiguredState()
-
- repository.setAutoSyncCallback {
- kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
+ @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+ private fun isNetworkAvailable(): Boolean {
+ val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return when {
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
+ else -> false
}
-
- // Perform image naming migration on initialization
- kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { performImageNamingMigration() }
}
- 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}"
- )
-
- 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
- }
-
- 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
- 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}")
- if (response.code != 200) {
- Log.w(TAG, "Failed request URL: ${request.url}")
- }
-
- 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")
- }
- }
-
+ @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
suspend fun syncWithServer() {
- if (!isConfigured) {
+ if (isOfflineMode) {
+ Log.d(TAG, "Sync skipped: Offline mode is enabled.")
+ return
+ }
+ if (!isNetworkAvailable()) {
+ _syncError.value = "No internet connection."
+ Log.d(TAG, "Sync skipped: No internet connection.")
+ return
+ }
+ if (!_isConfigured.value) {
throw SyncException.NotConfigured
}
-
if (!_isConnected.value) {
throw SyncException.NotConnected
}
@@ -361,20 +191,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_syncError.value = null
try {
- 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")
- }
-
- Log.d(TAG, "Performing image migration before sync")
- val migrationSuccess = migrateImagesForSync()
- if (!migrationSuccess) {
- Log.w(TAG, "Image migration failed, but continuing with sync")
- }
-
val localBackup = createBackupFromRepository()
-
val serverBackup = downloadData()
val hasLocalData =
@@ -403,9 +220,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
- Log.d(TAG, "Both local and server data exist, merging safely")
- mergeDataSafely(localBackup, serverBackup)
- Log.d(TAG, "Safe merge completed")
+ Log.d(TAG, "Both local and server data exist, merging (server wins)")
+ mergeDataSafely(serverBackup)
+ Log.d(TAG, "Merge completed")
}
else -> {
Log.d(TAG, "No data to sync")
@@ -414,7 +231,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
- sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply()
+ sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) }
} catch (e: Exception) {
_syncError.value = e.message
throw e
@@ -424,871 +241,329 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
+ private suspend fun downloadData(): ClimbDataBackup {
+ val request =
+ Request.Builder()
+ .url("$serverUrl/sync")
+ .header("Authorization", "Bearer $authToken")
+ .get()
+ .build()
+
+ return withContext(Dispatchers.IO) {
+ try {
+ httpClient.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ val body = response.body?.string()
+ if (!body.isNullOrEmpty()) {
+ json.decodeFromString(body)
+ } else {
+ ClimbDataBackup(
+ exportedAt = DateFormatUtils.nowISO8601(),
+ gyms = emptyList(),
+ problems = emptyList(),
+ sessions = emptyList(),
+ attempts = emptyList()
+ )
+ }
+ } else {
+ handleHttpError(response.code)
+ }
+ }
+ } catch (e: IOException) {
+ throw SyncException.NetworkError(e.message ?: "Network error")
+ }
+ }
+ }
+
+ private suspend fun uploadData(backup: ClimbDataBackup) {
+ val requestBody =
+ json.encodeToString(ClimbDataBackup.serializer(), backup)
+ .toRequestBody("application/json".toMediaType())
+
+ val request =
+ Request.Builder()
+ .url("$serverUrl/sync")
+ .header("Authorization", "Bearer $authToken")
+ .post(requestBody)
+ .build()
+
+ withContext(Dispatchers.IO) {
+ try {
+ httpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ handleHttpError(response.code)
+ }
+ }
+ } catch (e: IOException) {
+ throw SyncException.NetworkError(e.message ?: "Network error")
+ }
+ }
+ }
+
private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map {
val imagePathMapping = mutableMapOf()
+ val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 }
+ Log.d(TAG, "Starting image download from server for $totalImages images")
- 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?.forEach { imagePath ->
- try {
- // Use the server's actual filename, not regenerated
+ withContext(Dispatchers.IO) {
+ backup.problems.forEach { problem ->
+ problem.imagePaths?.forEach { imagePath ->
val serverFilename = imagePath.substringAfterLast('/')
-
- Log.d(TAG, "Attempting to download image: $serverFilename")
- val imageData = downloadImage(serverFilename)
-
- val localImagePath =
- ImageUtils.saveImageFromBytesWithFilename(
- context,
- imageData,
- serverFilename
- )
-
- if (localImagePath != null) {
- imagePathMapping[imagePath] = localImagePath
- downloadedImages++
- Log.d(
- TAG,
- "Downloaded and mapped image: $serverFilename -> $localImagePath"
- )
- } else {
- Log.w(TAG, "Failed to save downloaded image locally: $imagePath")
- failedImages++
+ try {
+ val localImagePath = downloadImage(serverFilename)
+ if (localImagePath != null) {
+ imagePathMapping[imagePath] = localImagePath
+ }
+ } catch (_: SyncException.ImageNotFound) {
+ Log.w(TAG, "Image not found on server: $imagePath")
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
}
- } catch (e: SyncException.ImageNotFound) {
- Log.w(
- TAG,
- "Image not found on server: $imagePath - might be missing or use different naming"
- )
- 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 downloadImage(serverFilename: String): String? {
+ val request =
+ Request.Builder()
+ .url("$serverUrl/images/download?filename=$serverFilename")
+ .header("Authorization", "Bearer $authToken")
+ .build()
+
+ return withContext(Dispatchers.IO) {
+ try {
+ httpClient.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ response.body?.bytes()?.let {
+ ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename)
+ }
+ } else {
+ if (response.code == 404) throw SyncException.ImageNotFound
+ null
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Network error downloading image $serverFilename", e)
+ null
+ }
+ }
+ }
+
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()
- // Always use consistent problem-based naming for uploads
- val consistentFilename =
- ImageNamingUtils.generateImageFilename(problem.id, index)
-
- val filename = imagePath.substringAfterLast('/')
-
- // Rename local file if needed
- if (filename != consistentFilename) {
- val newFile = java.io.File(imageFile.parent, consistentFilename)
- if (imageFile.renameTo(newFile)) {
- Log.d(
- TAG,
- "Renamed local image file: $filename -> $consistentFilename"
- )
- } else {
- Log.w(TAG, "Failed to rename local image file, using original")
- }
- }
-
- 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++
+ withContext(Dispatchers.IO) {
+ backup.problems.forEach { problem ->
+ problem.imagePaths?.forEach { localPath ->
+ val filename = localPath.substringAfterLast('/')
+ uploadImage(localPath, filename)
}
}
}
+ }
- Log.d(
- TAG,
- "Image sync completed: $uploadedImages uploaded, $failedImages failed, $totalImages total"
- )
+ private suspend fun uploadImage(localPath: String, filename: String) {
+ val file = ImageUtils.getImageFile(context, localPath)
+ if (!file.exists()) {
+ Log.w(TAG, "Local image file not found, cannot upload: $localPath")
+ return
+ }
+
+ val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType())
+
+ val request =
+ Request.Builder()
+ .url("$serverUrl/images/upload?filename=$filename")
+ .header("Authorization", "Bearer $authToken")
+ .post(requestBody)
+ .build()
+
+ withContext(Dispatchers.IO) {
+ try {
+ httpClient.newCall(request).execute().use { response ->
+ if (response.isSuccessful) {
+ Log.d(TAG, "Successfully uploaded image: $filename")
+ } else {
+ Log.w(
+ TAG,
+ "Failed to upload image $filename. Server responded with ${response.code}"
+ )
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Network error uploading image $filename", e)
+ }
+ }
}
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()
-
- // Filter out active sessions and their attempts from sync
- val completedSessions = allSessions.filter { it.status != SessionStatus.ACTIVE }
- val activeSessionIds =
- allSessions.filter { it.status == SessionStatus.ACTIVE }.map { it.id }.toSet()
- val completedAttempts = allAttempts.filter { !activeSessionIds.contains(it.sessionId) }
-
- Log.d(
- TAG,
- "Sync exclusions: ${allSessions.size - completedSessions.size} active sessions, ${allAttempts.size - completedAttempts.size} active session attempts"
- )
-
- return ClimbDataBackup(
- exportedAt = dataStateManager.getLastModified(),
- version = "2.0",
- formatVersion = "2.0",
- gyms = allGyms.map { BackupGym.fromGym(it) },
- problems =
- allProblems.map { problem ->
- // Normalize image paths to consistent naming in backup
- val normalizedImagePaths =
- problem.imagePaths?.mapIndexed { index, _ ->
- ImageNamingUtils.generateImageFilename(problem.id, index)
- }
-
- val backupProblem = BackupProblem.fromProblem(problem)
- if (!normalizedImagePaths.isNullOrEmpty()) {
- backupProblem.copy(imagePaths = normalizedImagePaths)
- } else {
- backupProblem
- }
- },
- sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
- attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
- deletedItems = repository.getDeletedItems()
- )
+ return withContext(Dispatchers.Default) {
+ ClimbDataBackup(
+ exportedAt = dataStateManager.getLastModified(),
+ gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) },
+ problems =
+ repository.getAllProblems().first().map { problem ->
+ val backupProblem = BackupProblem.fromProblem(problem)
+ val normalizedImagePaths =
+ problem.imagePaths.mapIndexed { index, _ ->
+ ImageNamingUtils.generateImageFilename(
+ problem.id,
+ index
+ )
+ }
+ if (normalizedImagePaths.isNotEmpty()) {
+ backupProblem.copy(imagePaths = normalizedImagePaths)
+ } else {
+ backupProblem
+ }
+ },
+ sessions =
+ repository.getAllSessions().first().map {
+ BackupClimbSession.fromClimbSession(it)
+ },
+ attempts =
+ repository.getAllAttempts().first().map {
+ BackupAttempt.fromAttempt(it)
+ },
+ deletedItems = repository.getDeletedItems()
+ )
+ }
}
private suspend fun importBackupToRepository(
backup: ClimbDataBackup,
- imagePathMapping: Map = emptyMap()
+ imagePathMapping: Map
) {
- // Store active sessions and their attempts before reset
- val activeSessions =
- repository.getAllSessions().first().filter { it.status == SessionStatus.ACTIVE }
- val activeSessionIds = activeSessions.map { it.id }.toSet()
- val activeAttempts =
- repository.getAllAttempts().first().filter {
- activeSessionIds.contains(it.sessionId)
+ val gyms = backup.gyms.map { it.toGym() }
+ val problems =
+ backup.problems.map { backupProblem ->
+ val imagePaths = backupProblem.imagePaths
+ val updatedImagePaths =
+ imagePaths?.map { oldPath ->
+ imagePathMapping[oldPath] ?: oldPath
+ }
+ backupProblem.copy(imagePaths = updatedImagePaths).toProblem()
}
-
- Log.d(
- TAG,
- "Preserving ${activeSessions.size} active sessions and ${activeAttempts.size} active attempts during import"
- )
+ val sessions = backup.sessions.map { it.toClimbSession() }
+ val attempts = backup.attempts.map { it.toAttempt() }
repository.resetAllData()
- // Filter out deleted gyms before importing
- val deletedGymIds = backup.deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
- backup.gyms.forEach { backupGym ->
- try {
- if (!deletedGymIds.contains(backupGym.id)) {
- val gym = backupGym.toGym()
- Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
- repository.insertGymWithoutSync(gym)
- } else {
- Log.d(TAG, "Skipping import of deleted gym: ${backupGym.id}")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
- throw e
- }
- }
+ gyms.forEach { repository.insertGymWithoutSync(it) }
+ problems.forEach { repository.insertProblemWithoutSync(it) }
+ sessions.forEach { repository.insertSessionWithoutSync(it) }
+ attempts.forEach { repository.insertAttemptWithoutSync(it) }
- // Filter out deleted problems before importing
- val deletedProblemIds =
- backup.deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
- backup.problems.forEach { backupProblem ->
- try {
- if (!deletedProblemIds.contains(backupProblem.id)) {
- val updatedProblem =
- if (imagePathMapping.isNotEmpty()) {
- val newImagePaths =
- backupProblem.imagePaths?.map { oldPath ->
- val filename = oldPath.substringAfterLast('/')
-
- imagePathMapping[filename]
- ?: if (ImageNamingUtils.isValidImageFilename(
- filename
- )
- ) {
- "problem_images/$filename"
- } else {
- 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())
- } else {
- Log.d(TAG, "Skipping import of deleted problem: ${backupProblem.id}")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}")
- }
- }
-
- // Filter out deleted sessions before importing
- val deletedSessionIds =
- backup.deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
- backup.sessions.forEach { backupSession ->
- try {
- if (!deletedSessionIds.contains(backupSession.id)) {
- repository.insertSessionWithoutSync(backupSession.toClimbSession())
- } else {
- Log.d(TAG, "Skipping import of deleted session: ${backupSession.id}")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}")
- }
- }
-
- // Filter out deleted attempts before importing
- val deletedAttemptIds =
- backup.deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
- backup.attempts.forEach { backupAttempt ->
- try {
- if (!deletedAttemptIds.contains(backupAttempt.id)) {
- repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
- } else {
- Log.d(TAG, "Skipping import of deleted attempt: ${backupAttempt.id}")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}")
- }
- }
-
- // Restore active sessions and their attempts after import
- activeSessions.forEach { session ->
- try {
- Log.d(TAG, "Restoring active session: ${session.id}")
- repository.insertSessionWithoutSync(session)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to restore active session '${session.id}': ${e.message}")
- }
- }
-
- activeAttempts.forEach { attempt ->
- try {
- repository.insertAttemptWithoutSync(attempt)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to restore active attempt '${attempt.id}': ${e.message}")
- }
- }
-
- // Import deletion records to prevent future resurrections
- backup.deletedItems.forEach { deletion ->
- try {
- val deletionJson = json.encodeToString(deletion)
- val preferences =
- context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
- preferences.edit { putString("deleted_${deletion.id}", deletionJson) }
- Log.d(TAG, "Imported deletion record: ${deletion.type} ${deletion.id}")
- } catch (e: Exception) {
- Log.e(TAG, "Failed to import deletion record: ${e.message}")
- }
- }
-
- dataStateManager.setLastModified(backup.exportedAt)
- Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
- }
-
- private suspend fun mergeDataSafely(
- localBackup: ClimbDataBackup,
- serverBackup: ClimbDataBackup
- ) {
- val imagePathMapping = syncImagesFromServer(serverBackup)
-
- // Get all local data
- val localGyms = repository.getAllGyms().first()
- val localProblems = repository.getAllProblems().first()
- val localSessions = repository.getAllSessions().first()
- val localAttempts = repository.getAllAttempts().first()
-
- // Store active sessions before clearing (but exclude any that were deleted)
- val localDeletedItems = repository.getDeletedItems()
- val allDeletedSessionIds =
- (serverBackup.deletedItems + localDeletedItems)
- .filter { it.type == "session" }
- .map { it.id }
- .toSet()
- val activeSessions =
- localSessions.filter {
- it.status == SessionStatus.ACTIVE && !allDeletedSessionIds.contains(it.id)
- }
- val activeSessionIds = activeSessions.map { it.id }.toSet()
- val allDeletedAttemptIds =
- (serverBackup.deletedItems + localDeletedItems)
- .filter { it.type == "attempt" }
- .map { it.id }
- .toSet()
- val activeAttempts =
- localAttempts.filter {
- activeSessionIds.contains(it.sessionId) && !allDeletedAttemptIds.contains(it.id)
- }
-
- // Merge deletion lists
- val localDeletions = repository.getDeletedItems()
- val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id }
-
- Log.d(TAG, "Merging data...")
- val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, allDeletions)
- val mergedProblems =
- mergeProblems(localProblems, serverBackup.problems, imagePathMapping, allDeletions)
- val mergedSessions = mergeSessions(localSessions, serverBackup.sessions, allDeletions)
- val mergedAttempts = mergeAttempts(localAttempts, serverBackup.attempts, allDeletions)
-
- // Clear and repopulate with merged data
- repository.resetAllData()
-
- mergedGyms.forEach { gym ->
- try {
- repository.insertGymWithoutSync(gym)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to insert merged gym: ${e.message}")
- }
- }
-
- mergedProblems.forEach { problem ->
- try {
- repository.insertProblemWithoutSync(problem)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to insert merged problem: ${e.message}")
- }
- }
-
- mergedSessions.forEach { session ->
- try {
- repository.insertSessionWithoutSync(session)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to insert merged session: ${e.message}")
- }
- }
-
- mergedAttempts.forEach { attempt ->
- try {
- repository.insertAttemptWithoutSync(attempt)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to insert merged attempt: ${e.message}")
- }
- }
-
- // Restore active sessions
- activeSessions.forEach { session ->
- try {
- repository.insertSessionWithoutSync(session)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to restore active session: ${e.message}")
- }
- }
-
- activeAttempts.forEach { attempt ->
- try {
- repository.insertAttemptWithoutSync(attempt)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to restore active attempt: ${e.message}")
- }
- }
-
- // Update local deletions with merged list
repository.clearDeletedItems()
- allDeletions.forEach { deletion ->
- try {
- val deletionJson = json.encodeToString(deletion)
- val preferences =
- context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
- preferences.edit { putString("deleted_${deletion.id}", deletionJson) }
- Log.d(TAG, "Merged deletion record: ${deletion.type} ${deletion.id}")
- } catch (e: Exception) {
- Log.e(TAG, "Failed to save merged deletion: ${e.message}")
- }
- }
-
- // Upload merged data back to server
- val mergedBackup = createBackupFromRepository()
- uploadData(mergedBackup)
- syncImagesForBackup(mergedBackup)
-
- dataStateManager.updateDataState()
}
- private fun mergeGyms(
- local: List,
- server: List,
- deletedItems: List
- ): List {
- val merged = local.toMutableList()
- val localIds = local.map { it.id }.toSet()
- val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
-
- merged.removeAll { deletedGymIds.contains(it.id) }
-
- server.forEach { serverGym ->
- if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
- try {
- merged.add(serverGym.toGym())
- } catch (e: Exception) {
- Log.e(TAG, "Failed to convert server gym: ${e.message}")
- }
- }
- }
-
- return merged
+ private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) {
+ Log.d(TAG, "Server data will overwrite local data. Performing full restore.")
+ val imagePathMapping = syncImagesFromServer(serverBackup)
+ importBackupToRepository(serverBackup, imagePathMapping)
}
- private fun mergeProblems(
- local: List,
- server: List,
- imagePathMapping: Map,
- deletedItems: List
- ): List {
- val merged = local.toMutableList()
- val localIds = local.map { it.id }.toSet()
- val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
-
- merged.removeAll { deletedProblemIds.contains(it.id) }
-
- server.forEach { serverProblem ->
- if (!localIds.contains(serverProblem.id) &&
- !deletedProblemIds.contains(serverProblem.id)
- ) {
- try {
- val problemToAdd =
- if (imagePathMapping.isNotEmpty() &&
- !serverProblem.imagePaths.isNullOrEmpty()
- ) {
- val updatedImagePaths =
- serverProblem.imagePaths?.mapNotNull { oldPath ->
- imagePathMapping[oldPath] ?: oldPath
- }
- if (updatedImagePaths != serverProblem.imagePaths) {
- serverProblem.copy(imagePaths = updatedImagePaths)
- } else {
- serverProblem
- }
- } else {
- serverProblem
- }
- merged.add(problemToAdd.toProblem())
- } catch (e: Exception) {
- Log.e(TAG, "Failed to convert server problem: ${e.message}")
- }
- }
- }
-
- return merged
- }
-
- private fun mergeSessions(
- local: List,
- server: List,
- deletedItems: List
- ): List {
- val merged = local.toMutableList()
- val localIds = local.map { it.id }.toSet()
- val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
-
- merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
-
- server.forEach { serverSession ->
- if (!localIds.contains(serverSession.id) &&
- !deletedSessionIds.contains(serverSession.id)
- ) {
- try {
- merged.add(serverSession.toClimbSession())
- } catch (e: Exception) {
- Log.e(TAG, "Failed to convert server session: ${e.message}")
- }
- }
- }
-
- return merged
- }
-
- private fun mergeAttempts(
- local: List,
- server: List,
- deletedItems: List
- ): List {
- val merged = local.toMutableList()
- val localIds = local.map { it.id }.toSet()
- val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
-
- merged.removeAll { deletedAttemptIds.contains(it.id) }
-
- server.forEach { serverAttempt ->
- if (!localIds.contains(serverAttempt.id) &&
- !deletedAttemptIds.contains(serverAttempt.id)
- ) {
- try {
- merged.add(serverAttempt.toAttempt())
- } catch (e: Exception) {
- Log.e(TAG, "Failed to convert server attempt: ${e.message}")
- }
- }
- }
-
- return merged
- }
-
- /** 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
- }
- }
-
- /**
- * 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("/")) {
- 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
+ private fun handleHttpError(code: Int): Nothing {
+ when (code) {
+ 401 -> throw SyncException.Unauthorized
+ in 500..599 -> throw SyncException.ServerError(code)
+ else -> throw SyncException.InvalidResponse("HTTP error code: $code")
}
}
suspend fun testConnection() {
- if (!isConfigured) {
- throw SyncException.NotConfigured
+ if (!_isConfigured.value) {
+ _isConnected.value = false
+ _syncError.value = "Server URL or Auth Token is not set."
+ return
}
-
_isTesting.value = true
_syncError.value = null
+ val request =
+ Request.Builder()
+ .url("$serverUrl/sync")
+ .header("Authorization", "Bearer $authToken")
+ .head()
+ .build()
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)
+ httpClient.newCall(request).execute().use { response ->
+ _isConnected.value = response.isSuccessful || response.code == 405
}
}
+ if (!_isConnected.value) {
+ _syncError.value = "Connection failed. Check URL and token."
+ }
} catch (e: Exception) {
_isConnected.value = false
- sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply()
- _syncError.value = e.message
- throw e
+ _syncError.value = "Connection error: ${e.message}"
} finally {
+ sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) }
_isTesting.value = false
}
}
- suspend fun triggerAutoSync() {
- if (!isConfigured || !_isConnected.value || !_isAutoSyncEnabled.value) {
+ fun triggerAutoSync() {
+ if (!_isConfigured.value || !_isConnected.value || !_isAutoSyncEnabled.value) {
return
}
-
if (_isSyncing.value) {
pendingChanges = true
return
}
-
syncJob?.cancel()
-
syncJob =
- kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
+ serviceScope.launch {
delay(syncDebounceDelay)
-
- do {
+ try {
+ syncWithServer()
+ } catch (e: Exception) {
+ Log.e(TAG, "Auto-sync failed", e)
+ }
+ if (pendingChanges) {
pendingChanges = false
-
- try {
- syncWithServer()
- } catch (e: Exception) {
- Log.e(TAG, "Auto-sync failed: ${e.message}")
- _syncError.value = e.message
- return@launch
- }
-
- if (pendingChanges) {
- delay(syncDebounceDelay)
- }
- } while (pendingChanges)
+ triggerAutoSync()
+ }
}
}
- suspend fun forceSyncNow() {
- if (!isConfigured || !_isConnected.value) return
-
- syncJob?.cancel()
- syncJob = null
- pendingChanges = false
-
- try {
- syncWithServer()
- } catch (e: Exception) {
- Log.e(TAG, "Force sync failed: ${e.message}")
- _syncError.value = e.message
- }
- }
-
fun clearConfiguration() {
syncJob?.cancel()
- syncJob = null
- pendingChanges = false
-
- serverURL = ""
+ serverUrl = ""
authToken = ""
setAutoSyncEnabled(true)
_lastSyncTime.value = null
_isConnected.value = false
_syncError.value = null
-
- sharedPreferences.edit().clear().apply()
+ sharedPreferences.edit { clear() }
updateConfiguredState()
}
-
- // MARK: - Image Naming Migration
-
- private suspend fun performImageNamingMigration() =
- withContext(Dispatchers.IO) {
- val migrationKey = "image_naming_migration_completed"
- if (sharedPreferences.getBoolean(migrationKey, false)) {
- Log.d(TAG, "Image naming migration already completed")
- return@withContext
- }
-
- Log.d(TAG, "Starting image naming migration...")
- var updateCount = 0
-
- try {
- // Get all problems with images
- val problems = repository.getAllProblems().first()
- val updatedProblems = mutableListOf()
-
- for (problem in problems) {
- if (problem.imagePaths.isNullOrEmpty()) {
- continue
- }
-
- val updatedImagePaths = mutableListOf()
- var hasChanges = false
-
- problem.imagePaths.forEachIndexed { index, imagePath ->
- val currentFilename = imagePath.substringAfterLast('/')
- val consistentFilename =
- ImageNamingUtils.generateImageFilename(problem.id, index)
-
- if (currentFilename != consistentFilename) {
- // Get the image file
- val oldFile = ImageUtils.getImageFile(context, imagePath)
-
- if (oldFile.exists()) {
- val newPath = "problem_images/$consistentFilename"
- val newFile = ImageUtils.getImageFile(context, newPath)
-
- try {
- // Create parent directory if needed
- newFile.parentFile?.mkdirs()
-
- if (oldFile.renameTo(newFile)) {
- updatedImagePaths.add(newPath)
- hasChanges = true
- updateCount++
- Log.d(
- TAG,
- "Migrated image: $currentFilename -> $consistentFilename"
- )
- } else {
- Log.w(TAG, "Failed to migrate image $currentFilename")
- updatedImagePaths.add(
- imagePath
- ) // Keep original on failure
- }
- } catch (e: Exception) {
- Log.w(
- TAG,
- "Failed to migrate image $currentFilename: ${e.message}"
- )
- updatedImagePaths.add(imagePath) // Keep original on failure
- }
- } else {
- updatedImagePaths.add(
- imagePath
- ) // Keep original if file doesn't exist
- }
- } else {
- updatedImagePaths.add(imagePath) // Already consistent
- }
- }
-
- if (hasChanges) {
- val updatedProblem =
- problem.copy(
- imagePaths = updatedImagePaths,
- updatedAt = DateFormatUtils.formatISO8601(Instant.now())
- )
- updatedProblems.add(updatedProblem)
- }
- }
-
- // Update problems in database
- if (updatedProblems.isNotEmpty()) {
- updatedProblems.forEach { problem -> repository.updateProblem(problem) }
- Log.d(
- TAG,
- "Updated ${updatedProblems.size} problems with migrated image paths"
- )
- }
-
- // Mark migration as completed
- sharedPreferences.edit().putBoolean(migrationKey, true).apply()
- Log.d(TAG, "Image naming migration completed, updated $updateCount images")
-
- // Trigger sync after migration if images were updated
- if (updateCount > 0) {
- Log.d(TAG, "Triggering sync after image migration")
- triggerAutoSync()
- }
- } catch (e: Exception) {
- Log.e(TAG, "Image naming migration failed: ${e.message}", e)
- }
- }
}
-sealed class SyncException(message: String) : Exception(message) {
+sealed class SyncException(message: String) : IOException(message), Serializable {
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.")
+ SyncException("Sync is not configured. Please set server URL and auth token.") {
+ @JvmStatic private fun readResolve(): Any = NotConfigured
+ }
+
+ object NotConnected : SyncException("Not connected to server. Please test connection first.") {
+ @JvmStatic private fun readResolve(): Any = NotConnected
+ }
+
+ object Unauthorized : SyncException("Unauthorized. Please check your auth token.") {
+ @JvmStatic private fun readResolve(): Any = Unauthorized
+ }
+
+ object ImageNotFound : SyncException("Image not found on server") {
+ @JvmStatic private fun readResolve(): Any = ImageNotFound
+ }
+
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/service/SessionTrackingService.kt b/android/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
index be28811..091d45c 100644
--- a/android/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/service/SessionTrackingService.kt
@@ -88,11 +88,7 @@ class SessionTrackingService : Service() {
return START_REDELIVER_INTENT
}
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- super.onTaskRemoved(rootIntent)
- }
-
+
override fun onBind(intent: Intent?): IBinder? = null
private fun startSessionTracking(sessionId: String) {
@@ -153,7 +149,7 @@ class SessionTrackingService : Service() {
return try {
val activeNotifications = notificationManager.activeNotifications
activeNotifications.any { it.id == NOTIFICATION_ID }
- } catch (e: Exception) {
+ } catch (_: Exception) {
false
}
}
diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
index 05c12f3..90d20b0 100644
--- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
+++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/ProblemsScreen.kt
@@ -4,6 +4,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -13,11 +16,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.atridad.openclimb.R
+import com.atridad.openclimb.data.model.Attempt
+import com.atridad.openclimb.data.model.AttemptResult
import com.atridad.openclimb.data.model.ClimbType
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
-import com.atridad.openclimb.ui.components.FullscreenImageViewer
-import com.atridad.openclimb.ui.components.ImageDisplay
import com.atridad.openclimb.ui.components.SyncIndicator
import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
@@ -26,10 +29,8 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
val problems by viewModel.problems.collectAsState()
val gyms by viewModel.gyms.collectAsState()
+ val attempts by viewModel.attempts.collectAsState()
val context = LocalContext.current
- var showImageViewer by remember { mutableStateOf(false) }
- var selectedImagePaths by remember { mutableStateOf>(emptyList()) }
- var selectedImageIndex by remember { mutableIntStateOf(0) }
// Filter state
var selectedClimbType by remember { mutableStateOf(null) }
@@ -178,12 +179,8 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
ProblemCard(
problem = problem,
gymName = gyms.find { it.id == problem.gymId }?.name ?: "Unknown Gym",
+ attempts = attempts,
onClick = { onNavigateToProblemDetail(problem.id) },
- onImageClick = { imagePaths, index ->
- selectedImagePaths = imagePaths
- selectedImageIndex = index
- showImageViewer = true
- },
onToggleActive = {
val updatedProblem = problem.copy(isActive = !problem.isActive)
viewModel.updateProblem(updatedProblem, context)
@@ -194,15 +191,6 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
}
}
}
-
- // Fullscreen Image Viewer
- if (showImageViewer && selectedImagePaths.isNotEmpty()) {
- FullscreenImageViewer(
- imagePaths = selectedImagePaths,
- initialIndex = selectedImageIndex,
- onDismiss = { showImageViewer = false }
- )
- }
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -210,10 +198,17 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
fun ProblemCard(
problem: Problem,
gymName: String,
+ attempts: List,
onClick: () -> Unit,
- onImageClick: ((List, Int) -> Unit)? = null,
onToggleActive: (() -> Unit)? = null
) {
+ val isCompleted =
+ attempts.any { attempt ->
+ attempt.problemId == problem.id &&
+ (attempt.result == AttemptResult.SUCCESS ||
+ attempt.result == AttemptResult.FLASH)
+ }
+
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
@@ -242,12 +237,35 @@ fun ProblemCard(
}
Column(horizontalAlignment = Alignment.End) {
- Text(
- text = problem.difficulty.grade,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (problem.imagePaths.isNotEmpty()) {
+ Icon(
+ imageVector = Icons.Default.Image,
+ contentDescription = "Has images",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ if (isCompleted) {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = "Completed",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.tertiary
+ )
+ }
+
+ Text(
+ text = problem.difficulty.grade,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
Text(
text = problem.climbType.getDisplayName(),
@@ -279,16 +297,6 @@ fun ProblemCard(
}
}
- // Display images if any
- if (problem.imagePaths.isNotEmpty()) {
- Spacer(modifier = Modifier.height(8.dp))
- ImageDisplay(
- imagePaths = problem.imagePaths.take(3), // Show max 3 images in list
- imageSize = 60,
- onImageClick = { index -> onImageClick?.invoke(problem.imagePaths, index) }
- )
- }
-
if (!problem.isActive) {
Spacer(modifier = Modifier.height(8.dp))
Text(
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 10a483e..8d9e0ea 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
@@ -44,9 +44,9 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
var showResetDialog by remember { mutableStateOf(false) }
var showSyncConfigDialog by remember { mutableStateOf(false) }
var showDisconnectDialog by remember { mutableStateOf(false) }
- var showFixImagesDialog by remember { mutableStateOf(false) }
+
var showDeleteImagesDialog by remember { mutableStateOf(false) }
- var isFixingImages by remember { mutableStateOf(false) }
+
var isDeletingImages by remember { mutableStateOf(false) }
// Sync configuration state
@@ -484,46 +484,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(8.dp))
- Card(
- shape = RoundedCornerShape(12.dp),
- colors =
- CardDefaults.cardColors(
- containerColor =
- MaterialTheme.colorScheme.surfaceVariant.copy(
- alpha = 0.3f
- )
- )
- ) {
- ListItem(
- headlineContent = { Text("Fix Image Names") },
- supportingContent = {
- Text(
- "Rename all images to use consistent naming across devices"
- )
- },
- leadingContent = {
- Icon(Icons.Default.Build, contentDescription = null)
- },
- trailingContent = {
- TextButton(
- onClick = { showFixImagesDialog = true },
- enabled = !isFixingImages && !uiState.isLoading
- ) {
- if (isFixingImages) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp
- )
- } else {
- Text("Fix Names")
- }
- }
- }
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
Card(
shape = RoundedCornerShape(12.dp),
colors =
@@ -1005,35 +965,6 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
)
}
- // Fix Image Names dialog
- if (showFixImagesDialog) {
- AlertDialog(
- onDismissRequest = { showFixImagesDialog = false },
- title = { Text("Fix Image Names") },
- text = {
- Text(
- "This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
- )
- },
- confirmButton = {
- TextButton(
- onClick = {
- isFixingImages = true
- showFixImagesDialog = false
- coroutineScope.launch {
- viewModel.migrateImageNamesToDeterministic(context)
- isFixingImages = false
- viewModel.setMessage("Image names fixed successfully!")
- }
- }
- ) { Text("Fix Names") }
- },
- dismissButton = {
- TextButton(onClick = { showFixImagesDialog = false }) { Text("Cancel") }
- }
- )
- }
-
// Delete All Images dialog
if (showDeleteImagesDialog) {
AlertDialog(
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 48ca6f8..cc24c84 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
@@ -171,64 +171,6 @@ class ClimbViewModel(
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
}
- fun migrateImageNamesToDeterministic(context: Context) {
- viewModelScope.launch {
- val allProblems = repository.getAllProblems().first()
- var migrationCount = 0
- val updatedProblems = mutableListOf()
-
- for (problem in allProblems) {
- if (problem.imagePaths.isEmpty()) continue
-
- var newImagePaths = mutableListOf()
- var problemNeedsUpdate = false
-
- for ((index, imagePath) in problem.imagePaths.withIndex()) {
- val currentFilename = File(imagePath).name
-
- if (ImageNamingUtils.isValidImageFilename(currentFilename)) {
- newImagePaths.add(imagePath)
- continue
- }
-
- val deterministicName =
- ImageNamingUtils.generateImageFilename(problem.id, index)
-
- val imagesDir = ImageUtils.getImagesDirectory(context)
- val oldFile = File(imagesDir, currentFilename)
- val newFile = File(imagesDir, deterministicName)
-
- if (oldFile.exists()) {
- if (oldFile.renameTo(newFile)) {
- newImagePaths.add(deterministicName)
- problemNeedsUpdate = true
- migrationCount++
- println("Migrated: $currentFilename → $deterministicName")
- } else {
- println("Failed to migrate $currentFilename")
- newImagePaths.add(imagePath)
- }
- } else {
- println("Warning: Image file not found: $currentFilename")
- newImagePaths.add(imagePath)
- }
- }
-
- if (problemNeedsUpdate) {
- val updatedProblem = problem.copy(imagePaths = newImagePaths)
- updatedProblems.add(updatedProblem)
- }
- }
-
- for (updatedProblem in updatedProblems) {
- repository.insertProblemWithoutSync(updatedProblem)
- }
-
- println(
- "Migration completed: $migrationCount images renamed, ${updatedProblems.size} problems updated"
- )
- }
- }
fun deleteAllImages(context: Context) {
viewModelScope.launch {
diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj
index 48e7589..c9c880d 100644
--- a/ios/OpenClimb.xcodeproj/project.pbxproj
+++ b/ios/OpenClimb.xcodeproj/project.pbxproj
@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 24;
+ CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
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 f6ec780..4b2b45d 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/Components/AsyncImageView.swift b/ios/OpenClimb/Components/AsyncImageView.swift
new file mode 100644
index 0000000..6cdfc9b
--- /dev/null
+++ b/ios/OpenClimb/Components/AsyncImageView.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct AsyncImageView: View {
+ let imagePath: String
+ let targetSize: CGSize
+
+ @State private var image: UIImage?
+
+ var body: some View {
+ ZStack {
+ Rectangle()
+ .fill(Color(.systemGray6))
+
+ if let image = image {
+ Image(uiImage: image)
+ .resizable()
+ .scaledToFill()
+ .transition(.opacity.animation(.easeInOut(duration: 0.3)))
+ } else {
+ Image(systemName: "photo")
+ .font(.system(size: 24))
+ .foregroundColor(Color(.systemGray3))
+ }
+ }
+ .frame(width: targetSize.width, height: targetSize.height)
+ .clipped()
+ .cornerRadius(8)
+ .task(id: imagePath) {
+ if self.image != nil {
+ self.image = nil
+ }
+
+ self.image = await ImageManager.shared.loadThumbnail(
+ fromPath: imagePath,
+ targetSize: targetSize
+ )
+ }
+ }
+}
diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift
index e84535d..3460f46 100644
--- a/ios/OpenClimb/Services/SyncService.swift
+++ b/ios/OpenClimb/Services/SyncService.swift
@@ -9,6 +9,7 @@ class SyncService: ObservableObject {
@Published var syncError: String?
@Published var isConnected = false
@Published var isTesting = false
+ @Published var isOfflineMode = false
private let userDefaults = UserDefaults.standard
private var syncTask: Task?
@@ -19,8 +20,9 @@ class SyncService: ObservableObject {
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 isConnected = "is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
+ static let offlineMode = "offline_mode"
}
var serverURL: String {
@@ -46,12 +48,9 @@ class SyncService: ObservableObject {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
- self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
-
- // Perform image naming migration on initialization
- Task {
- await performImageNamingMigration()
- }
+ isConnected = userDefaults.bool(forKey: Keys.isConnected)
+ isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
+ isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
}
func downloadData() async throws -> ClimbDataBackup {
@@ -211,6 +210,11 @@ class SyncService: ObservableObject {
}
func syncWithServer(dataManager: ClimbingDataManager) async throws {
+ if isOfflineMode {
+ print("Sync skipped: Offline mode is enabled.")
+ return
+ }
+
guard isConfigured else {
throw SyncError.notConfigured
}
@@ -1025,105 +1029,7 @@ class SyncService: ObservableObject {
syncTask?.cancel()
}
- // MARK: - Image Naming Migration
-
- private func performImageNamingMigration() async {
- let migrationKey = "image_naming_migration_completed_v2"
- guard !userDefaults.bool(forKey: migrationKey) else {
- print("Image naming migration already completed")
- return
- }
-
- print("Starting image naming migration...")
- var updateCount = 0
- let imageManager = ImageManager.shared
-
- // Get all problems from UserDefaults
- if let problemsData = userDefaults.data(forKey: "problems"),
- var problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
- {
-
- for problemIndex in 0.. \(consistentFilename)")
- } catch {
- print("Failed to migrate image \(currentFilename): \(error)")
- updatedImagePaths.append(imagePath)
- }
- } else {
- updatedImagePaths.append(imagePath)
- }
- } else {
- updatedImagePaths.append(imagePath)
- }
- }
-
- if hasChanges {
- // Decode problem to dictionary, update imagePaths, re-encode
- if let problemData = try? JSONEncoder().encode(problem),
- var problemDict = try? JSONSerialization.jsonObject(with: problemData)
- as? [String: Any]
- {
- problemDict["imagePaths"] = updatedImagePaths
- problemDict["updatedAt"] = ISO8601DateFormatter().string(from: Date())
- if let updatedData = try? JSONSerialization.data(
- withJSONObject: problemDict),
- let updatedProblem = try? JSONDecoder().decode(
- Problem.self, from: updatedData)
- {
- problems[problemIndex] = updatedProblem
- }
- }
- }
- }
-
- if updateCount > 0 {
- if let updatedData = try? JSONEncoder().encode(problems) {
- userDefaults.set(updatedData, forKey: "problems")
- print("Updated \(updateCount) image paths in UserDefaults")
- }
- }
- }
-
- userDefaults.set(true, forKey: migrationKey)
- print("Image naming migration completed, updated \(updateCount) images")
-
- // Notify ClimbingDataManager to reload data if images were updated
- if updateCount > 0 {
- DispatchQueue.main.async {
- NotificationCenter.default.post(
- name: NSNotification.Name("ImageMigrationCompleted"),
- object: nil,
- userInfo: ["updateCount": updateCount]
- )
- }
- }
- }
-
+ // MARK: - Merging
// MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
diff --git a/ios/OpenClimb/Utils/ImageManager.swift b/ios/OpenClimb/Utils/ImageManager.swift
index e10e8e3..80bdcbb 100644
--- a/ios/OpenClimb/Utils/ImageManager.swift
+++ b/ios/OpenClimb/Utils/ImageManager.swift
@@ -1,10 +1,12 @@
import Foundation
+import ImageIO
import SwiftUI
import UIKit
class ImageManager {
static let shared = ImageManager()
+ private let thumbnailCache = NSCache()
private let fileManager = FileManager.default
private let appSupportDirectoryName = "OpenClimb"
private let imagesDirectoryName = "Images"
@@ -479,6 +481,51 @@ class ImageManager {
return nil
}
+ func loadThumbnail(fromPath path: String, targetSize: CGSize) async -> UIImage? {
+ let cacheKey = "\(path)-\(targetSize.width)x\(targetSize.height)" as NSString
+
+ if let cachedImage = thumbnailCache.object(forKey: cacheKey) {
+ return cachedImage
+ }
+
+ guard let imageData = loadImageData(fromPath: path) else {
+ return nil
+ }
+
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceShouldCacheImmediately: true,
+ kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height)
+ * UIScreen.main.scale,
+ ]
+
+ guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
+ return UIImage(data: imageData)
+ }
+
+ let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
+ let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 ?? 1
+
+ if let cgImage = CGImageSourceCreateThumbnailAtIndex(
+ imageSource, 0, options as CFDictionary)
+ {
+ let imageOrientation = UIImage.Orientation(rawValue: Int(orientation - 1)) ?? .up
+ let thumbnail = UIImage(
+ cgImage: cgImage, scale: UIScreen.main.scale, orientation: imageOrientation)
+
+ thumbnailCache.setObject(thumbnail, forKey: cacheKey)
+ return thumbnail
+ } else {
+ if let fallbackImage = UIImage(data: imageData) {
+ thumbnailCache.setObject(fallbackImage, forKey: cacheKey)
+ return fallbackImage
+ }
+ }
+
+ return nil
+ }
+
func imageExists(atPath path: String) -> Bool {
let primaryPath = getFullPath(from: path)
let backupPath = backupDirectory.appendingPathComponent(getRelativePath(from: path))
@@ -854,72 +901,4 @@ class ImageManager {
}
}
- func migrateImageNamesToDeterministic(dataManager: ClimbingDataManager) {
- print("Starting migration of image names to deterministic format...")
-
- var migrationCount = 0
- var updatedProblems: [Problem] = []
-
- for problem in dataManager.problems {
- guard !problem.imagePaths.isEmpty else { continue }
-
- var newImagePaths: [String] = []
- var problemNeedsUpdate = false
-
- for (index, imagePath) in problem.imagePaths.enumerated() {
- let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
-
- if ImageNamingUtils.isValidImageFilename(currentFilename) {
- newImagePaths.append(imagePath)
- continue
- }
-
- let deterministicName = ImageNamingUtils.generateImageFilename(
- problemId: problem.id.uuidString, imageIndex: index)
-
- let oldPath = imagesDirectory.appendingPathComponent(currentFilename)
- let newPath = imagesDirectory.appendingPathComponent(deterministicName)
-
- if fileManager.fileExists(atPath: oldPath.path) {
- do {
- try fileManager.moveItem(at: oldPath, to: newPath)
-
- let oldBackupPath = backupDirectory.appendingPathComponent(currentFilename)
- let newBackupPath = backupDirectory.appendingPathComponent(
- deterministicName)
-
- if fileManager.fileExists(atPath: oldBackupPath.path) {
- try? fileManager.moveItem(at: oldBackupPath, to: newBackupPath)
- }
-
- newImagePaths.append(deterministicName)
- problemNeedsUpdate = true
- migrationCount += 1
-
- print("Migrated: \(currentFilename) → \(deterministicName)")
-
- } catch {
- print("Failed to migrate \(currentFilename): \(error)")
- newImagePaths.append(imagePath)
- }
- } else {
- print("Warning: Image file not found: \(currentFilename)")
- newImagePaths.append(imagePath)
- }
- }
-
- if problemNeedsUpdate {
- let updatedProblem = problem.updated(imagePaths: newImagePaths)
- updatedProblems.append(updatedProblem)
- }
- }
-
- for updatedProblem in updatedProblems {
- dataManager.updateProblem(updatedProblem)
- }
-
- print(
- "Migration completed: \(migrationCount) images renamed, \(updatedProblems.count) problems updated"
- )
- }
}
diff --git a/ios/OpenClimb/Utils/OrientationAwareImage.swift b/ios/OpenClimb/Utils/OrientationAwareImage.swift
index a12c228..3e1057c 100644
--- a/ios/OpenClimb/Utils/OrientationAwareImage.swift
+++ b/ios/OpenClimb/Utils/OrientationAwareImage.swift
@@ -37,7 +37,7 @@ struct OrientationAwareImage: View {
}
private func loadImageWithCorrectOrientation() {
- Task {
+ Task.detached(priority: .userInitiated) {
let correctedImage = await loadAndCorrectImage()
await MainActor.run {
self.uiImage = correctedImage
@@ -48,17 +48,10 @@ struct OrientationAwareImage: View {
}
private func loadAndCorrectImage() async -> UIImage? {
- // Load image data from ImageManager
- guard
- let data = await MainActor.run(body: {
- ImageManager.shared.loadImageData(fromPath: imagePath)
- })
- else { return nil }
+ guard let data = ImageManager.shared.loadImageData(fromPath: imagePath) else { return nil }
- // Create UIImage from data
guard let originalImage = UIImage(data: data) else { return nil }
- // Apply orientation correction
return correctImageOrientation(originalImage)
}
diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift
index 3c113b6..5d59bde 100644
--- a/ios/OpenClimb/Views/ProblemsView.swift
+++ b/ios/OpenClimb/Views/ProblemsView.swift
@@ -9,8 +9,26 @@ struct ProblemsView: View {
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
- private var filteredProblems: [Problem] {
- var filtered = dataManager.problems
+ @State private var cachedFilteredProblems: [Problem] = []
+
+ private func updateFilteredProblems() {
+ Task(priority: .userInitiated) {
+ let result = await computeFilteredProblems()
+ // Switch back to the main thread to update the UI
+ await MainActor.run {
+ cachedFilteredProblems = result
+ }
+ }
+ }
+
+ private func computeFilteredProblems() async -> [Problem] {
+ // Capture dependencies for safe background processing
+ let problems = dataManager.problems
+ let searchText = self.searchText
+ let selectedClimbType = self.selectedClimbType
+ let selectedGym = self.selectedGym
+
+ var filtered = problems
// Apply search filter
if !searchText.isEmpty {
@@ -93,19 +111,19 @@ struct ProblemsView: View {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
- filteredProblems: filteredProblems
+ filteredProblems: cachedFilteredProblems
)
.padding()
.background(.regularMaterial)
}
- if filteredProblems.isEmpty {
+ if cachedFilteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
- ProblemsList(problems: filteredProblems)
+ ProblemsList(problems: cachedFilteredProblems)
}
}
}
@@ -158,6 +176,21 @@ struct ProblemsView: View {
AddEditProblemView()
}
}
+ .onAppear {
+ updateFilteredProblems()
+ }
+ .onChange(of: dataManager.problems) {
+ updateFilteredProblems()
+ }
+ .onChange(of: searchText) {
+ updateFilteredProblems()
+ }
+ .onChange(of: selectedClimbType) {
+ updateFilteredProblems()
+ }
+ .onChange(of: selectedGym) {
+ updateFilteredProblems()
+ }
}
}
@@ -269,6 +302,7 @@ struct ProblemsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
+ @State private var animationKey = 0
var body: some View {
List(problems, id: \.id) { problem in
@@ -309,8 +343,11 @@ struct ProblemsList: View {
}
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
- value: problems.map { "\($0.id):\($0.isActive)" }.joined()
+ value: animationKey
)
+ .onChange(of: problems) {
+ animationKey += 1
+ }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
@@ -344,6 +381,12 @@ struct ProblemRow: View {
dataManager.gym(withId: problem.gymId)
}
+ private var isCompleted: Bool {
+ dataManager.attempts.contains { attempt in
+ attempt.problemId == problem.id && attempt.result.isSuccessful
+ }
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -361,10 +404,24 @@ struct ProblemRow: View {
Spacer()
VStack(alignment: .trailing, spacing: 4) {
- Text(problem.difficulty.grade)
- .font(.title2)
- .fontWeight(.bold)
- .foregroundColor(.blue)
+ HStack(spacing: 8) {
+ if !problem.imagePaths.isEmpty {
+ Image(systemName: "photo")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(.blue)
+ }
+
+ if isCompleted {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(.green)
+ }
+
+ Text(problem.difficulty.grade)
+ .font(.title2)
+ .fontWeight(.bold)
+ .foregroundColor(.blue)
+ }
Text(problem.climbType.displayName)
.font(.caption)
@@ -396,17 +453,6 @@ struct ProblemRow: View {
}
}
- if !problem.imagePaths.isEmpty {
- ScrollView(.horizontal, showsIndicators: false) {
- LazyHStack(spacing: 8) {
- ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
- ProblemImageView(imagePath: imagePath)
- }
- }
- .padding(.horizontal, 4)
- }
- }
-
if !problem.isActive {
Text("Reset / No Longer Set")
.font(.caption)
@@ -478,17 +524,6 @@ struct EmptyProblemsView: View {
}
}
-struct ProblemImageView: View {
- let imagePath: String
-
- var body: some View {
- OrientationAwareImage.fill(imagePath: imagePath)
- .frame(width: 60, height: 60)
- .clipped()
- .cornerRadius(8)
- }
-}
-
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)
diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift
index 73c5205..e11efb9 100644
--- a/ios/OpenClimb/Views/SettingsView.swift
+++ b/ios/OpenClimb/Views/SettingsView.swift
@@ -80,8 +80,7 @@ struct DataManagementSection: View {
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
- @State private var isMigrating = false
- @State private var showingMigrationAlert = false
+
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = false
@@ -121,27 +120,6 @@ struct DataManagementSection: View {
}
.foregroundColor(.primary)
- // Migrate Image Names
- Button(action: {
- showingMigrationAlert = true
- }) {
- HStack {
- if isMigrating {
- ProgressView()
- .scaleEffect(0.8)
- Text("Migrating Images...")
- .foregroundColor(.secondary)
- } else {
- Image(systemName: "photo.badge.arrow.down")
- .foregroundColor(.orange)
- Text("Fix Image Names")
- }
- Spacer()
- }
- }
- .disabled(isMigrating)
- .foregroundColor(.primary)
-
// Delete All Images
Button(action: {
showingDeleteImagesAlert = true
@@ -186,16 +164,7 @@ struct DataManagementSection: View {
"Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first."
)
}
- .alert("Fix Image Names", isPresented: $showingMigrationAlert) {
- Button("Cancel", role: .cancel) {}
- Button("Fix Names") {
- migrateImageNames()
- }
- } message: {
- Text(
- "This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
- )
- }
+
.alert("Delete All Images", isPresented: $showingDeleteImagesAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
@@ -219,17 +188,6 @@ struct DataManagementSection: View {
}
}
- private func migrateImageNames() {
- isMigrating = true
- Task {
- await MainActor.run {
- ImageManager.shared.migrateImageNamesToDeterministic(dataManager: dataManager)
- isMigrating = false
- dataManager.successMessage = "Image names fixed successfully!"
- }
- }
- }
-
private func deleteAllImages() {
isDeletingImages = true
Task {
diff --git a/ios/README.md b/ios/README.md
new file mode 100644
index 0000000..53b59c6
--- /dev/null
+++ b/ios/README.md
@@ -0,0 +1,23 @@
+# OpenClimb for iOS
+
+The native iOS, watchOS, and widget client for OpenClimb, built with Swift and SwiftUI.
+
+## Project Structure
+
+This is a standard Xcode project. The main app code is in the `OpenClimb/` directory.
+
+- `Models/`: Swift `Codable` models (`Problem`, `Gym`, `ClimbSession`) that match the Android app.
+- `ViewModels/`: App state and logic. `ClimbingDataManager` is the core here, handling data with SwiftData.
+- `Views/`: All the SwiftUI views.
+ - `AddEdit/`: Views for adding/editing gyms, problems, etc.
+ - `Detail/`: Detail views for items.
+- `Services/`: Handles HealthKit and sync server communication.
+- `Utils/`: Helper functions and utilities.
+
+## Other Targets
+
+- `OpenClimbWatch/`: The watchOS app for tracking sessions.
+- `ClimbingActivityWidget/`: A home screen widget.
+- `SessionStatusLive/`: A Live Activity for the lock screen.
+
+The app is built to be offline-first. All data is stored locally on your device and works without an internet connection.
\ No newline at end of file
diff --git a/sync/README.md b/sync/README.md
new file mode 100644
index 0000000..53e3a33
--- /dev/null
+++ b/sync/README.md
@@ -0,0 +1,37 @@
+# Sync Server
+
+A simple Go server for self-hosting your OpenClimb sync data.
+
+## How It Works
+
+This server is dead simple. It uses a single `openclimb.json` file for your data and a directory for images. The last client to upload wins, overwriting the old data. Authentication is just a static bearer token.
+
+## Getting Started
+
+1. Create a `.env` file in this directory:
+ ```
+ IMAGE=git.atri.dad/atridad/openclimb-sync:latest
+ APP_PORT=8080
+ AUTH_TOKEN=your-super-secret-token
+ DATA_FILE=/data/openclimb.json
+ IMAGES_DIR=/data/images
+ ROOT_DIR=./openclimb-data
+ ```
+ Set `AUTH_TOKEN` to a long, random string. `ROOT_DIR` is where the server will store its data on your machine.
+
+2. Run with Docker:
+ ```bash
+ docker-compose up -d
+ ```
+ The server will be running on `http://localhost:8080`.
+
+## API
+
+The API is minimal, just enough for the app to work. All endpoints require an `Authorization: Bearer ` header.
+
+- `GET /sync`: Download `openclimb.json`.
+- `POST /sync`: Upload `openclimb.json`.
+- `GET /images/{imageName}`: Download an image.
+- `POST /images/{imageName}`: Upload an image.
+
+Check out `main.go` for the full details.
\ No newline at end of file