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