Compare commits

..

3 Commits

Author SHA1 Message Date
6a39d23f28 [iOS & Android] iOS 1.3.0 & Android 1.8.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
2025-10-09 21:00:12 -06:00
603a683ab2 Fixed major issue with sync logic. Should be stable now. Solidified with
tests... turns out syncing is hard...
2025-10-06 18:04:56 -06:00
a19ff8ef66 [iOS & Android] iOS 1.2.5 & Android 1.7.4 [Sync] Sync 1.1.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m25s
2025-10-06 17:38:19 -06:00
20 changed files with 1353 additions and 294 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 32 versionCode = 35
versionName = "1.7.3" versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -12,7 +12,15 @@ data class ClimbDataBackup(
val gyms: List<BackupGym>, val gyms: List<BackupGym>,
val problems: List<BackupProblem>, val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>, val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt> val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem> = emptyList()
)
@Serializable
data class DeletedItem(
val id: String,
val type: String, // "gym", "problem", "session", "attempt"
val deletedAt: String
) )
// Platform-neutral gym representation for backup/restore // Platform-neutral gym representation for backup/restore

View File

@@ -1,12 +1,15 @@
package com.atridad.openclimb.data.repository package com.atridad.openclimb.data.repository
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.openclimb.data.format.ClimbDataBackup
import com.atridad.openclimb.data.format.DeletedItem
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.state.DataStateManager import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.openclimb.utils.DateFormatUtils
@@ -14,6 +17,8 @@ import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File import java.io.File
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
@@ -22,6 +27,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private val sessionDao = database.climbSessionDao() private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao() private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context) private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null private var autoSyncCallback: (() -> Unit)? = null
@@ -45,6 +52,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
suspend fun deleteGym(gym: Gym) { suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym) gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
@@ -56,17 +64,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertProblem(problem: Problem) { suspend fun insertProblem(problem: Problem) {
problemDao.insertProblem(problem) problemDao.insertProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync()
} }
suspend fun updateProblem(problem: Problem) { suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem) problemDao.updateProblem(problem)
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync()
} }
suspend fun deleteProblem(problem: Problem) { suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem) problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync()
} }
// Session operations // Session operations
@@ -94,6 +100,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
suspend fun deleteSession(session: ClimbSession) { suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session) sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState() dataStateManager.updateDataState()
triggerAutoSync() triggerAutoSync()
} }
@@ -122,6 +129,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
} }
suspend fun deleteAttempt(attempt: Attempt) { suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt) attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
dataStateManager.updateDataState() dataStateManager.updateDataState()
} }
@@ -261,6 +269,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
autoSyncCallback?.invoke() autoSyncCallback?.invoke()
} }
private fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList()
val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion)
deletionPreferences.edit { putString("deleted_${itemId}", json) }
}
fun getDeletedItems(): List<DeletedItem> {
val deletions = mutableListOf<DeletedItem>()
val allPrefs = deletionPreferences.all
for ((key, value) in allPrefs) {
if (key.startsWith("deleted_") && value is String) {
try {
val deletion = json.decodeFromString<DeletedItem>(value)
deletions.add(deletion)
} catch (e: Exception) {
// Invalid deletion record, ignore
}
}
}
return deletions
}
fun clearDeletedItems() {
deletionPreferences.edit { clear() }
}
private fun validateDataIntegrity( private fun validateDataIntegrity(
gyms: List<Gym>, gyms: List<Gym>,
problems: List<Problem>, problems: List<Problem>,

View File

@@ -9,7 +9,12 @@ import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupGym
import com.atridad.openclimb.data.format.BackupProblem import com.atridad.openclimb.data.format.BackupProblem
import com.atridad.openclimb.data.format.ClimbDataBackup 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.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.model.SessionStatus
import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.state.DataStateManager import com.atridad.openclimb.data.state.DataStateManager
@@ -108,7 +113,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
updateConfiguredState() updateConfiguredState()
// Clear connection status when configuration changes // Clear connection status when configuration changes
_isConnected.value = false _isConnected.value = false
sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) }
} }
val isConfigured: Boolean val isConfigured: Boolean
@@ -382,27 +387,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Initial upload completed") Log.d(TAG, "Initial upload completed")
} }
hasLocalData && hasServerData -> { hasLocalData && hasServerData -> {
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) Log.d(TAG, "Both local and server data exist, merging safely")
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) mergeDataSafely(localBackup, serverBackup)
Log.d(TAG, "Safe merge completed")
Log.d(
TAG,
"Comparing timestamps: local=$localTimestamp, server=$serverTimestamp"
)
if (localTimestamp > serverTimestamp) {
Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) {
Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data")
} else {
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
}
} }
else -> { else -> {
Log.d(TAG, "No data to sync") Log.d(TAG, "No data to sync")
@@ -583,7 +570,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
gyms = allGyms.map { BackupGym.fromGym(it) }, gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) }, problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) }, sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) } attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
deletedItems = repository.getDeletedItems()
) )
} }
@@ -607,19 +595,29 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.resetAllData() repository.resetAllData()
// Filter out deleted gyms before importing
val deletedGymIds = backup.deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
backup.gyms.forEach { backupGym -> backup.gyms.forEach { backupGym ->
try { try {
if (!deletedGymIds.contains(backupGym.id)) {
val gym = backupGym.toGym() val gym = backupGym.toGym()
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
repository.insertGymWithoutSync(gym) repository.insertGymWithoutSync(gym)
} else {
Log.d(TAG, "Skipping import of deleted gym: ${backupGym.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
throw e throw e
} }
} }
// Filter out deleted problems before importing
val deletedProblemIds =
backup.deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
backup.problems.forEach { backupProblem -> backup.problems.forEach { backupProblem ->
try { try {
if (!deletedProblemIds.contains(backupProblem.id)) {
val updatedProblem = val updatedProblem =
if (imagePathMapping.isNotEmpty()) { if (imagePathMapping.isNotEmpty()) {
val newImagePaths = val newImagePaths =
@@ -638,7 +636,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
oldPath oldPath
) )
val consistentFilename = val consistentFilename =
ImageNamingUtils.generateImageFilename( ImageNamingUtils
.generateImageFilename(
backupProblem.id, backupProblem.id,
index index
) )
@@ -651,22 +650,39 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backupProblem backupProblem
} }
repository.insertProblemWithoutSync(updatedProblem.toProblem()) repository.insertProblemWithoutSync(updatedProblem.toProblem())
} else {
Log.d(TAG, "Skipping import of deleted problem: ${backupProblem.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}") 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 -> backup.sessions.forEach { backupSession ->
try { try {
if (!deletedSessionIds.contains(backupSession.id)) {
repository.insertSessionWithoutSync(backupSession.toClimbSession()) repository.insertSessionWithoutSync(backupSession.toClimbSession())
} else {
Log.d(TAG, "Skipping import of deleted session: ${backupSession.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}") 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 -> backup.attempts.forEach { backupAttempt ->
try { try {
if (!deletedAttemptIds.contains(backupAttempt.id)) {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
} else {
Log.d(TAG, "Skipping import of deleted attempt: ${backupAttempt.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}") Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}")
} }
@@ -690,10 +706,265 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
} }
// 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) dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${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<Gym>,
server: List<BackupGym>,
deletedItems: List<DeletedItem>
): List<Gym> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedGymIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
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 fun mergeProblems(
local: List<Problem>,
server: List<BackupProblem>,
imagePathMapping: Map<String, String>,
deletedItems: List<DeletedItem>
): List<Problem> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedProblemIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverProblem ->
if (!localIds.contains(serverProblem.id) &&
!deletedProblemIds.contains(serverProblem.id)
) {
try {
val problemToAdd =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
serverProblem.imagePaths?.map { oldPath ->
val filename = oldPath.substringAfterLast('/')
imagePathMapping[filename] ?: oldPath
}
?: emptyList()
serverProblem.withUpdatedImagePaths(newImagePaths)
} 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<ClimbSession>,
server: List<BackupClimbSession>,
deletedItems: List<DeletedItem>
): List<ClimbSession> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but never remove active sessions)
merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
// Add new items from server (excluding deleted ones)
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<Attempt>,
server: List<BackupAttempt>,
deletedItems: List<DeletedItem>
): List<Attempt> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { deletedAttemptIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
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 */ /** Parses ISO8601 timestamp to milliseconds for comparison */
private fun parseISO8601ToMillis(timestamp: String): Long { private fun parseISO8601ToMillis(timestamp: String): Long {
return try { return try {

View File

@@ -1,5 +1,9 @@
package com.atridad.openclimb.ui.components package com.atridad.openclimb.ui.components
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,7 +12,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -17,8 +23,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable @Composable
fun ImagePicker( fun ImagePicker(
@@ -29,9 +40,12 @@ fun ImagePicker(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var tempImageUris by remember { mutableStateOf(imageUris) } var tempImageUris by remember { mutableStateOf(imageUris) }
var showImageSourceDialog by remember { mutableStateOf(false) }
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// Image picker launcher // Image picker launcher
val imagePickerLauncher = rememberLauncherForActivityResult( val imagePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents() contract = ActivityResultContracts.GetMultipleContents()
) { uris -> ) { uris ->
if (uris.isNotEmpty()) { if (uris.isNotEmpty()) {
@@ -56,6 +70,41 @@ fun ImagePicker(
} }
} }
// Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) {
success ->
if (success) {
cameraImageUri?.let { uri ->
val imagePath = ImageUtils.saveImageFromUri(context, uri)
if (imagePath != null) {
val updatedUris = tempImageUris + imagePath
tempImageUris = updatedUris
onImagesChanged(updatedUris)
}
}
}
}
// Camera permission launcher
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Create image file for camera
val imageFile = createImageFile(context)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
cameraImageUri = uri
cameraLauncher.launch(uri)
}
}
Column(modifier = modifier) { Column(modifier = modifier) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -68,12 +117,12 @@ fun ImagePicker(
) )
if (tempImageUris.size < maxImages) { if (tempImageUris.size < maxImages) {
TextButton( TextButton(onClick = { showImageSourceDialog = true }) {
onClick = { Icon(
imagePickerLauncher.launch("image/*") Icons.Default.Add,
} contentDescription = null,
) { modifier = Modifier.size(16.dp)
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Add Photos") Text("Add Photos")
} }
@@ -83,9 +132,7 @@ fun ImagePicker(
if (tempImageUris.isNotEmpty()) { if (tempImageUris.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tempImageUris) { imagePath -> items(tempImageUris) { imagePath ->
ImageItem( ImageItem(
imagePath = imagePath, imagePath = imagePath,
@@ -103,20 +150,17 @@ fun ImagePicker(
} else { } else {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(100.dp),
.fillMaxWidth() colors =
.height(100.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor =
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.3f
)
) )
) { ) {
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxSize(), Column(horizontalAlignment = Alignment.CenterHorizontally) {
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
contentDescription = null, contentDescription = null,
@@ -132,48 +176,108 @@ fun ImagePicker(
} }
} }
} }
// Image Source Selection Dialog
if (showImageSourceDialog) {
AlertDialog(
onDismissRequest = { showImageSourceDialog = false },
title = { Text("Add Photo") },
text = { Text("Choose how you'd like to add a photo") },
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
showImageSourceDialog = false
imagePickerLauncher.launch("image/*")
} }
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Gallery")
}
TextButton(
onClick = {
showImageSourceDialog = false
when (ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
)
) {
PackageManager.PERMISSION_GRANTED -> {
// Create image file for camera
val imageFile = createImageFile(context)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
imageFile
)
cameraImageUri = uri
cameraLauncher.launch(uri)
}
else -> {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
}
}
}
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Camera")
}
}
},
dismissButton = {
TextButton(onClick = { showImageSourceDialog = false }) { Text("Cancel") }
}
)
}
}
}
private fun createImageFile(context: android.content.Context): File {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val imageFileName = "JPEG_${timeStamp}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(imageFileName, ".jpg", storageDir)
} }
@Composable @Composable
private fun ImageItem( private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifier = Modifier) {
imagePath: String,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
Box( Box(modifier = modifier.size(80.dp)) {
modifier = modifier.size(80.dp)
) {
AsyncImage( AsyncImage(
model = imageFile, model = imageFile,
contentDescription = "Problem photo", contentDescription = "Problem photo",
modifier = Modifier modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
IconButton( IconButton(onClick = onRemove, modifier = Modifier.align(Alignment.TopEnd).size(24.dp)) {
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.size(24.dp)
) {
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer containerColor = MaterialTheme.colorScheme.errorContainer
) )
) { ) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
contentDescription = "Remove photo", contentDescription = "Remove photo",
modifier = Modifier modifier = Modifier.fillMaxSize().padding(2.dp),
.fillMaxSize()
.padding(2.dp),
tint = MaterialTheme.colorScheme.onErrorContainer tint = MaterialTheme.colorScheme.onErrorContainer
) )
} }

View File

@@ -11,8 +11,8 @@ androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0" activityCompose = "1.11.0"
composeBom = "2025.09.01" composeBom = "2025.10.00"
room = "2.8.1" room = "2.8.2"
navigation = "2.9.5" navigation = "2.9.5"
viewmodel = "2.9.4" viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0" kotlinxSerialization = "1.9.0"

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -485,7 +485,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.4; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -508,7 +508,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -528,7 +528,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.4; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -592,7 +592,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -603,7 +603,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.4; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -622,7 +622,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -633,7 +633,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2.4; MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -0,0 +1,54 @@
import SwiftUI
import UIKit
struct CameraImagePicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onImageCaptured: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
picker.cameraDevice = .rear
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// Nothing here actually... Q_Q
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraImagePicker
init(_ parent: CameraImagePicker) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage {
parent.onImageCaptured(image)
}
parent.isPresented = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
}
}
// Extension to check camera availability
extension CameraImagePicker {
static var isCameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
}

View File

@@ -0,0 +1,83 @@
import PhotosUI
import SwiftUI
struct PhotoOptionSheet: View {
@Binding var selectedPhotos: [PhotosPickerItem]
@Binding var imageData: [Data]
let maxImages: Int
let onCameraSelected: () -> Void
let onPhotoLibrarySelected: () -> Void
let onDismiss: () -> Void
var body: some View {
NavigationView {
VStack(spacing: 20) {
Text("Add Photo")
.font(.title2)
.fontWeight(.semibold)
.padding(.top)
Text("Choose how you'd like to add a photo")
.font(.subheadline)
.foregroundColor(.secondary)
VStack(spacing: 16) {
Button(action: {
onPhotoLibrarySelected()
onDismiss()
}) {
HStack {
Image(systemName: "photo.on.rectangle")
.font(.title2)
.foregroundColor(.blue)
Text("Photo Library")
.font(.headline)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
}
.buttonStyle(PlainButtonStyle())
Button(action: {
onCameraSelected()
onDismiss()
}) {
HStack {
Image(systemName: "camera.fill")
.font(.title2)
.foregroundColor(.blue)
Text("Camera")
.font(.headline)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal)
Spacer()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
onDismiss()
}
}
}
}
.presentationDetents([.height(300)])
.interactiveDismissDisabled(false)
}
}

View File

@@ -57,7 +57,7 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
setupNotificationObservers() setupNotificationObservers()
// Trigger auto-sync on app launch // Trigger auto-sync on app start only
dataManager.syncService.triggerAutoSync(dataManager: dataManager) dataManager.syncService.triggerAutoSync(dataManager: dataManager)
} }
.onDisappear { .onDisappear {
@@ -103,8 +103,6 @@ struct ContentView: View {
Task { Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()
// Trigger auto-sync when app becomes active
await dataManager.syncService.triggerAutoSync(dataManager: dataManager)
} }
} }

View File

@@ -8,5 +8,7 @@
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to add photos to climbing problems.</string> <string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to take photos of climbing problems.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -17,3 +17,4 @@ extension SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
} }
} }

View File

@@ -6,6 +6,12 @@ import Foundation
// MARK: - Backup Format Specification v2.0 // MARK: - Backup Format Specification v2.0
/// Root structure for OpenClimb backup data /// Root structure for OpenClimb backup data
struct DeletedItem: Codable, Hashable {
let id: String
let type: String // "gym", "problem", "session", "attempt"
let deletedAt: String
}
struct ClimbDataBackup: Codable { struct ClimbDataBackup: Codable {
let exportedAt: String let exportedAt: String
let version: String let version: String
@@ -14,6 +20,7 @@ struct ClimbDataBackup: Codable {
let problems: [BackupProblem] let problems: [BackupProblem]
let sessions: [BackupClimbSession] let sessions: [BackupClimbSession]
let attempts: [BackupAttempt] let attempts: [BackupAttempt]
let deletedItems: [DeletedItem]
init( init(
exportedAt: String, exportedAt: String,
@@ -22,7 +29,8 @@ struct ClimbDataBackup: Codable {
gyms: [BackupGym], gyms: [BackupGym],
problems: [BackupProblem], problems: [BackupProblem],
sessions: [BackupClimbSession], sessions: [BackupClimbSession],
attempts: [BackupAttempt] attempts: [BackupAttempt],
deletedItems: [DeletedItem] = []
) { ) {
self.exportedAt = exportedAt self.exportedAt = exportedAt
self.version = version self.version = version
@@ -31,6 +39,7 @@ struct ClimbDataBackup: Codable {
self.problems = problems self.problems = problems
self.sessions = sessions self.sessions = sessions
self.attempts = attempts self.attempts = attempts
self.deletedItems = deletedItems
} }
} }

View File

@@ -247,39 +247,13 @@ class SyncService: ObservableObject {
try await syncImagesToServer(dataManager: dataManager) try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed") print("Initial upload completed")
} else if hasLocalData && hasServerData { } else if hasLocalData && hasServerData {
// Case 3: Both have data - compare timestamps (last writer wins) // Case 3: Both have data - use safe merge strategy
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt) print("iOS SYNC: Case 3 - Merging local and server data safely")
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt) try await mergeDataSafely(
localBackup: localBackup,
print("DEBUG iOS Timestamp Comparison:") serverBackup: serverBackup,
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)") dataManager: dataManager)
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") print("Safe merge completed")
print(
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
)
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
print(
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else { } else {
print("No data to sync") print("No data to sync")
} }
@@ -413,21 +387,102 @@ class SyncService: ObservableObject {
gyms: dataManager.gyms.map { BackupGym(from: $0) }, gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { BackupProblem(from: $0) }, problems: dataManager.problems.map { BackupProblem(from: $0) },
sessions: completedSessions.map { BackupClimbSession(from: $0) }, sessions: completedSessions.map { BackupClimbSession(from: $0) },
attempts: completedAttempts.map { BackupAttempt(from: $0) } attempts: completedAttempts.map { BackupAttempt(from: $0) },
deletedItems: dataManager.getDeletedItems()
) )
} }
private func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
dataManager: ClimbingDataManager
) async throws {
// Download server images first
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
// Merge deletion lists first to prevent resurrection of deleted items
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
print("Merging gyms...")
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: uniqueDeletions)
print("Merging problems...")
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: uniqueDeletions)
print("Merging sessions...")
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: uniqueDeletions)
print("Merging attempts...")
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: uniqueDeletions)
// Update data manager with merged data
dataManager.gyms = mergedGyms
dataManager.problems = mergedProblems
dataManager.sessions = mergedSessions
dataManager.attempts = mergedAttempts
// Save all data
dataManager.saveGyms()
dataManager.saveProblems()
dataManager.saveSessions()
dataManager.saveAttempts()
dataManager.saveActiveSession()
// Update local deletions with merged list
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(uniqueDeletions) {
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
}
// Upload merged data back to server
let mergedBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(mergedBackup)
try await syncImagesToServer(dataManager: dataManager)
// Update timestamp
DataStateManager.shared.updateDataState()
}
private func importBackupToDataManager( private func importBackupToDataManager(
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager, _ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:] imagePathMapping: [String: String] = [:]
) throws { ) throws {
do { do {
// Store active sessions and their attempts before import (but exclude any that were deleted)
// Store active sessions and their attempts before import let localDeletedItems = dataManager.getDeletedItems()
let activeSessions = dataManager.sessions.filter { $0.status == .active } let allDeletedSessionIds = Set(
(backup.deletedItems + localDeletedItems)
.filter { $0.type == "session" }
.map { $0.id }
)
let activeSessions = dataManager.sessions.filter {
$0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString)
}
let activeSessionIds = Set(activeSessions.map { $0.id }) let activeSessionIds = Set(activeSessions.map { $0.id })
let allDeletedAttemptIds = Set(
(backup.deletedItems + localDeletedItems)
.filter { $0.type == "attempt" }
.map { $0.id }
)
let activeAttempts = dataManager.attempts.filter { let activeAttempts = dataManager.attempts.filter {
activeSessionIds.contains($0.sessionId) activeSessionIds.contains($0.sessionId)
&& !allDeletedAttemptIds.contains($0.id.uuidString)
} }
print( print(
@@ -458,18 +513,58 @@ class SyncService: ObservableObject {
updatedAt: problem.updatedAt updatedAt: problem.updatedAt
) )
} }
// Filter out deleted items before creating updated backup
let deletedGymIds = Set(
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
let deletedProblemIds = Set(
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
let deletedSessionIds = Set(
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
let deletedAttemptIds = Set(
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
let filteredProblems = updatedProblems.filter { !deletedProblemIds.contains($0.id) }
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
updatedBackup = ClimbDataBackup( updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt, exportedAt: backup.exportedAt,
version: backup.version, version: backup.version,
formatVersion: backup.formatVersion, formatVersion: backup.formatVersion,
gyms: backup.gyms, gyms: filteredGyms,
problems: updatedProblems, problems: filteredProblems,
sessions: backup.sessions, sessions: filteredSessions,
attempts: backup.attempts attempts: filteredAttempts,
deletedItems: backup.deletedItems
) )
} else { } else {
updatedBackup = backup // Filter out deleted items even when no image path mapping
let deletedGymIds = Set(
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
let deletedProblemIds = Set(
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
let deletedSessionIds = Set(
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
let deletedAttemptIds = Set(
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
let filteredProblems = backup.problems.filter { !deletedProblemIds.contains($0.id) }
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt,
version: backup.version,
formatVersion: backup.formatVersion,
gyms: filteredGyms,
problems: filteredProblems,
sessions: filteredSessions,
attempts: filteredAttempts,
deletedItems: backup.deletedItems
)
} }
// Create a minimal ZIP with just the JSON data for existing import mechanism // Create a minimal ZIP with just the JSON data for existing import mechanism
@@ -496,12 +591,18 @@ class SyncService: ObservableObject {
dataManager.saveAttempts() dataManager.saveAttempts()
dataManager.saveActiveSession() dataManager.saveActiveSession()
// Import deletion records to prevent future resurrections
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(backup.deletedItems) {
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
}
// Update local data state to match imported data timestamp // Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt) DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)") print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch { } catch {
throw SyncError.importFailed(error) throw SyncError.importFailed(error)
} }
} }
@@ -817,6 +918,151 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled) userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
} }
// MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
{
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
// Remove items that were deleted on other devices
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id })
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
merged.append(serverGymConverted)
}
}
}
return merged
}
private func mergeProblems(
local: [Problem],
server: [BackupProblem],
imagePathMapping: [String: String],
deletedItems: [DeletedItem]
) throws -> [Problem] {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
// Remove items that were deleted on other devices
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverProblem in server {
var problemToAdd = serverProblem
// Update image paths if needed
if !imagePathMapping.isEmpty {
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
if !localHasProblem && !isDeleted {
merged.append(serverProblemConverted)
}
}
}
return merged
}
private func mergeSessions(
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
) throws
-> [ClimbSession]
{
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
// Remove items that were deleted on other devices (but never remove active sessions)
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
// Add new items from server (excluding deleted ones)
for serverSession in server {
if let serverSessionConverted = try? serverSession.toClimbSession() {
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
merged.append(serverSessionConverted)
}
}
}
return merged
}
private func mergeAttempts(
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
) throws -> [Attempt] {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
return attempt.sessionId
}.filter { sessionId in
// Check if this session ID belongs to an active session
// For now, we'll be conservative and not delete attempts during merge
return true
})
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { attempt in
deletedAttemptIds.contains(attempt.id.uuidString)
&& !activeSessionIds.contains(attempt.sessionId)
}
// Add new items from server (excluding deleted ones)
for serverAttempt in server {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
merged.append(serverAttemptConverted)
}
}
}
return merged
}
} }
enum SyncError: LocalizedError { enum SyncError: LocalizedError {

View File

@@ -41,6 +41,7 @@ class ClimbingDataManager: ObservableObject {
static let sessions = "openclimb_sessions" static let sessions = "openclimb_sessions"
static let attempts = "openclimb_attempts" static let attempts = "openclimb_attempts"
static let activeSession = "openclimb_active_session" static let activeSession = "openclimb_active_session"
static let deletedItems = "openclimb_deleted_items"
} }
// Widget data models // Widget data models
@@ -137,7 +138,7 @@ class ClimbingDataManager: ObservableObject {
} }
} }
private func saveGyms() { internal func saveGyms() {
if let data = try? encoder.encode(gyms) { if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms) userDefaults.set(data, forKey: Keys.gyms)
// Share with widget - convert to widget format // Share with widget - convert to widget format
@@ -150,7 +151,7 @@ class ClimbingDataManager: ObservableObject {
} }
} }
private func saveProblems() { internal func saveProblems() {
if let data = try? encoder.encode(problems) { if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems) userDefaults.set(data, forKey: Keys.problems)
// Share with widget // Share with widget
@@ -246,6 +247,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the gym // Delete the gym
gyms.removeAll { $0.id == gym.id } gyms.removeAll { $0.id == gym.id }
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
saveGyms() saveGyms()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully" successMessage = "Gym deleted successfully"
@@ -293,6 +295,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the problem // Delete the problem
problems.removeAll { $0.id == problem.id } problems.removeAll { $0.id == problem.id }
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
saveProblems() saveProblems()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
@@ -396,6 +399,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the session // Delete the session
sessions.removeAll { $0.id == session.id } sessions.removeAll { $0.id == session.id }
trackDeletion(itemId: session.id.uuidString, itemType: "session")
saveSessions() saveSessions()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
@@ -442,6 +446,7 @@ class ClimbingDataManager: ObservableObject {
func deleteAttempt(_ attempt: Attempt) { func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id } attempts.removeAll { $0.id == attempt.id }
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
saveAttempts() saveAttempts()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
@@ -453,6 +458,36 @@ class ClimbingDataManager: ObservableObject {
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp } return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
} }
// MARK: - Deletion Tracking
private func trackDeletion(itemId: String, itemType: String) {
let deletion = DeletedItem(
id: itemId,
type: itemType,
deletedAt: ISO8601DateFormatter().string(from: Date())
)
var currentDeletions = getDeletedItems()
currentDeletions.append(deletion)
if let data = try? encoder.encode(currentDeletions) {
userDefaults.set(data, forKey: Keys.deletedItems)
}
}
func getDeletedItems() -> [DeletedItem] {
guard let data = userDefaults.data(forKey: Keys.deletedItems),
let deletions = try? decoder.decode([DeletedItem].self, from: data)
else {
return []
}
return deletions
}
func clearDeletedItems() {
userDefaults.removeObject(forKey: Keys.deletedItems)
}
func attempts(forProblem problemId: UUID) -> [Attempt] { func attempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp } return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
} }

View File

@@ -23,6 +23,22 @@ struct AddAttemptView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @State private var imageData: [Data] = []
enum SheetType: Identifiable {
case photoOptions
case camera
var id: Int {
switch self {
case .photoOptions: return 0
case .camera: return 1
}
}
}
@State private var activeSheet: SheetType?
@State private var showPhotoPicker = false
@State private var isPhotoPickerActionPending = false
private var activeProblems: [Problem] { private var activeProblems: [Problem] {
dataManager.activeProblems(forGym: gym.id) dataManager.activeProblems(forGym: gym.id)
} }
@@ -78,6 +94,56 @@ struct AddAttemptView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() resetGradeIfNeeded()
} }
.onChange(of: selectedPhotos) {
Task {
await loadSelectedPhotos()
}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotos,
maxSelectionCount: 5 - imageData.count,
matching: .images
)
.sheet(
item: $activeSheet,
onDismiss: {
if isPhotoPickerActionPending {
showPhotoPicker = true
isPhotoPickerActionPending = false
}
}
) { sheetType in
switch sheetType {
case .photoOptions:
PhotoOptionSheet(
selectedPhotos: $selectedPhotos,
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
activeSheet = .camera
},
onPhotoLibrarySelected: {
isPhotoPickerActionPending = true
},
onDismiss: {
activeSheet = nil
}
)
case .camera:
CameraImagePicker(
isPresented: Binding(
get: { activeSheet == .camera },
set: { if !$0 { activeSheet = nil } }
)
) { capturedImage in
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
imageData.append(jpegData)
}
}
}
}
} }
@ViewBuilder @ViewBuilder
@@ -216,11 +282,9 @@ struct AddAttemptView: View {
} }
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -240,11 +304,7 @@ struct AddAttemptView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.onChange(of: selectedPhotos) { _, _ in .disabled(imageData.count >= 5)
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -378,6 +438,21 @@ struct AddAttemptView: View {
} }
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData.append(contentsOf: newImageData)
selectedPhotos.removeAll()
}
}
private func saveAttempt() { private func saveAttempt() {
if showingCreateProblem { if showingCreateProblem {
let difficulty = DifficultyGrade( let difficulty = DifficultyGrade(
@@ -436,19 +511,6 @@ struct AddAttemptView: View {
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
struct ProblemSelectionRow: View { struct ProblemSelectionRow: View {
@@ -696,6 +758,22 @@ struct EditAttemptView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @State private var imageData: [Data] = []
enum SheetType: Identifiable {
case photoOptions
case camera
var id: Int {
switch self {
case .photoOptions: return 0
case .camera: return 1
}
}
}
@State private var activeSheet: SheetType?
@State private var showPhotoPicker = false
@State private var isPhotoPickerActionPending = false
private var availableProblems: [Problem] { private var availableProblems: [Problem] {
guard let session = dataManager.session(withId: attempt.sessionId) else { guard let session = dataManager.session(withId: attempt.sessionId) else {
return [] return []
@@ -772,6 +850,56 @@ struct EditAttemptView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() resetGradeIfNeeded()
} }
.onChange(of: selectedPhotos) {
Task {
await loadSelectedPhotos()
}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotos,
maxSelectionCount: 5 - imageData.count,
matching: .images
)
.sheet(
item: $activeSheet,
onDismiss: {
if isPhotoPickerActionPending {
showPhotoPicker = true
isPhotoPickerActionPending = false
}
}
) { sheetType in
switch sheetType {
case .photoOptions:
PhotoOptionSheet(
selectedPhotos: $selectedPhotos,
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
activeSheet = .camera
},
onPhotoLibrarySelected: {
isPhotoPickerActionPending = true
},
onDismiss: {
activeSheet = nil
}
)
case .camera:
CameraImagePicker(
isPresented: Binding(
get: { activeSheet == .camera },
set: { if !$0 { activeSheet = nil } }
)
) { capturedImage in
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
imageData.append(jpegData)
}
}
}
}
} }
@ViewBuilder @ViewBuilder
@@ -910,11 +1038,9 @@ struct EditAttemptView: View {
} }
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -934,11 +1060,7 @@ struct EditAttemptView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.onChange(of: selectedPhotos) { _, _ in .disabled(imageData.count >= 5)
Task {
await loadSelectedPhotos()
}
}
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -1074,6 +1196,21 @@ struct EditAttemptView: View {
} }
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData.append(contentsOf: newImageData)
selectedPhotos.removeAll()
}
}
private func updateAttempt() { private func updateAttempt() {
if showingCreateProblem { if showingCreateProblem {
guard let gym = gym else { return } guard let gym = gym else { return }
@@ -1131,19 +1268,6 @@ struct EditAttemptView: View {
dismiss() dismiss()
} }
private func loadSelectedPhotos() async {
var newImageData: [Data] = []
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
newImageData.append(data)
}
}
await MainActor.run {
imageData = newImageData
}
}
} }
#Preview { #Preview {

View File

@@ -22,6 +22,21 @@ struct AddEditProblemView: View {
@State private var selectedPhotos: [PhotosPickerItem] = [] @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = [] @State private var imageData: [Data] = []
@State private var isEditing = false @State private var isEditing = false
enum SheetType: Identifiable {
case photoOptions
case camera
var id: Int {
switch self {
case .photoOptions: return 0
case .camera: return 1
}
}
}
@State private var activeSheet: SheetType?
@State private var showPhotoPicker = false
@State private var isPhotoPickerActionPending = false
private var existingProblem: Problem? { private var existingProblem: Problem? {
guard let problemId = problemId else { return nil } guard let problemId = problemId else { return nil }
@@ -87,6 +102,12 @@ struct AddEditProblemView: View {
loadExistingProblem() loadExistingProblem()
setupInitialGym() setupInitialGym()
} }
.onChange(of: dataManager.gyms) {
// Ensure a gym is selected when gyms are loaded or changed
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
}
.onChange(of: selectedGym) { .onChange(of: selectedGym) {
updateAvailableOptions() updateAvailableOptions()
} }
@@ -96,11 +117,56 @@ struct AddEditProblemView: View {
.onChange(of: selectedDifficultySystem) { .onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded() resetGradeIfNeeded()
} }
.sheet(
item: $activeSheet,
onDismiss: {
if isPhotoPickerActionPending {
showPhotoPicker = true
isPhotoPickerActionPending = false
}
}
) { sheetType in
switch sheetType {
case .photoOptions:
PhotoOptionSheet(
selectedPhotos: $selectedPhotos,
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
activeSheet = .camera
},
onPhotoLibrarySelected: {
isPhotoPickerActionPending = true
},
onDismiss: {
activeSheet = nil
}
)
case .camera:
CameraImagePicker(
isPresented: Binding(
get: { activeSheet == .camera },
set: { if !$0 { activeSheet = nil } }
)
) { capturedImage in
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
imageData.append(jpegData)
}
}
}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotos,
maxSelectionCount: 5 - imageData.count,
matching: .images
)
.onChange(of: selectedPhotos) { .onChange(of: selectedPhotos) {
Task { Task {
await loadSelectedPhotos() await loadSelectedPhotos()
} }
} }
} }
@ViewBuilder @ViewBuilder
@@ -302,11 +368,9 @@ struct AddEditProblemView: View {
@ViewBuilder @ViewBuilder
private func PhotosSection() -> some View { private func PhotosSection() -> some View {
Section("Photos (Optional)") { Section("Photos (Optional)") {
PhotosPicker( Button(action: {
selection: $selectedPhotos, activeSheet = .photoOptions
maxSelectionCount: 5, }) {
matching: .images
) {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -326,6 +390,7 @@ struct AddEditProblemView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.disabled(imageData.count >= 5)
if !imageData.isEmpty { if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
@@ -398,9 +463,14 @@ struct AddEditProblemView: View {
} }
private func setupInitialGym() { private func setupInitialGym() {
if let gymId = gymId, selectedGym == nil { if let gymId = gymId {
selectedGym = dataManager.gym(withId: gymId) selectedGym = dataManager.gym(withId: gymId)
} }
// Always ensure a gym is selected if available and none is currently selected
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
} }
private func loadExistingProblem() { private func loadExistingProblem() {
@@ -466,18 +536,14 @@ struct AddEditProblemView: View {
private func loadSelectedPhotos() async { private func loadSelectedPhotos() async {
for item in selectedPhotos { for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) { if let data = try? await item.loadTransferable(type: Data.self) {
// Use ImageManager to save image
if let relativePath = ImageManager.shared.saveImageData(data) {
imagePaths.append(relativePath)
imageData.append(data) imageData.append(data)
} }
} }
}
selectedPhotos.removeAll() selectedPhotos.removeAll()
} }
private func saveProblem() { private func saveProblem() {
guard let gym = selectedGym else { return } guard let gym = selectedGym, canSave else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -490,6 +556,14 @@ struct AddEditProblemView: View {
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade) let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
// Save new image data and combine with existing paths
var allImagePaths = imagePaths
for data in imageData {
if let relativePath = ImageManager.shared.saveImageData(data) {
allImagePaths.append(relativePath)
}
}
if isEditing, let problem = existingProblem { if isEditing, let problem = existingProblem {
let updatedProblem = problem.updated( let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName, name: trimmedName.isEmpty ? nil : trimmedName,
@@ -499,7 +573,7 @@ struct AddEditProblemView: View {
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: allImagePaths,
isActive: isActive, isActive: isActive,
dateSet: dateSet, dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes notes: trimmedNotes.isEmpty ? nil : trimmedNotes
@@ -515,7 +589,7 @@ struct AddEditProblemView: View {
tags: trimmedTags, tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation, location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: imagePaths, imagePaths: allImagePaths,
dateSet: dateSet, dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes notes: trimmedNotes.isEmpty ? nil : trimmedNotes
) )

View File

@@ -13,6 +13,8 @@ import (
"time" "time"
) )
const VERSION = "1.1.0"
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
@@ -20,6 +22,12 @@ func min(a, b int) int {
return b return b
} }
type DeletedItem struct {
ID string `json:"id"`
Type string `json:"type"`
DeletedAt string `json:"deletedAt"`
}
type ClimbDataBackup struct { type ClimbDataBackup struct {
ExportedAt string `json:"exportedAt"` ExportedAt string `json:"exportedAt"`
Version string `json:"version"` Version string `json:"version"`
@@ -28,6 +36,7 @@ type ClimbDataBackup struct {
Problems []BackupProblem `json:"problems"` Problems []BackupProblem `json:"problems"`
Sessions []BackupClimbSession `json:"sessions"` Sessions []BackupClimbSession `json:"sessions"`
Attempts []BackupAttempt `json:"attempts"` Attempts []BackupAttempt `json:"attempts"`
DeletedItems []DeletedItem `json:"deletedItems"`
} }
type BackupGym struct { type BackupGym struct {
@@ -120,6 +129,7 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
Problems: []BackupProblem{}, Problems: []BackupProblem{},
Sessions: []BackupClimbSession{}, Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{}, Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}, nil }, nil
} }
@@ -216,6 +226,7 @@ func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
"status": "healthy", "status": "healthy",
"version": VERSION,
"time": time.Now().UTC().Format(time.RFC3339), "time": time.Now().UTC().Format(time.RFC3339),
}) })
} }
@@ -347,7 +358,7 @@ func main() {
http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/upload", server.handleImageUpload)
http.HandleFunc("/images/download", server.handleImageDownload) http.HandleFunc("/images/download", server.handleImageDownload)
fmt.Printf("OpenClimb sync server starting on port %s\n", port) fmt.Printf("OpenClimb sync server v%s starting on port %s\n", VERSION, port)
fmt.Printf("Data file: %s\n", dataFile) fmt.Printf("Data file: %s\n", dataFile)
fmt.Printf("Images directory: %s\n", imagesDir) fmt.Printf("Images directory: %s\n", imagesDir)
fmt.Printf("Health check available at /health\n") fmt.Printf("Health check available at /health\n")

View File

@@ -1 +0,0 @@
1.0.0