Compare commits

...

2 Commits

Author SHA1 Message Date
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
12 changed files with 747 additions and 124 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 = 33
versionName = "1.7.3" versionName = "1.7.4"
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
@@ -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,272 @@ 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)
}
Log.d(TAG, "Merging data...")
val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems)
val mergedProblems =
mergeProblems(
localProblems,
serverBackup.problems,
imagePathMapping,
serverBackup.deletedItems
)
val mergedSessions =
mergeSessions(localSessions, serverBackup.sessions, serverBackup.deletedItems)
val mergedAttempts =
mergeAttempts(localAttempts, serverBackup.attempts, serverBackup.deletedItems)
// 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}")
}
}
// Merge deletion lists
val localDeletions = repository.getDeletedItems()
val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id }
// Clear and 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

@@ -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 = 16;
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.2.5;
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 = 16;
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.2.5;
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 = 16;
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.2.5;
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 = 16;
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.2.5;
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

@@ -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

@@ -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,103 @@ 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 data additively - never remove existing local data
print("Merging gyms...")
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: serverBackup.deletedItems)
print("Merging problems...")
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: serverBackup.deletedItems)
print("Merging sessions...")
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: serverBackup.deletedItems)
print("Merging attempts...")
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: serverBackup.deletedItems)
// 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()
// Merge deletion lists
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
// 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 +514,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 +592,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 +919,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

@@ -20,6 +20,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 +34,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 +127,7 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
Problems: []BackupProblem{}, Problems: []BackupProblem{},
Sessions: []BackupClimbSession{}, Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{}, Attempts: []BackupAttempt{},
DeletedItems: []DeletedItem{},
}, nil }, nil
} }

View File

@@ -1 +1 @@
1.0.0 1.1.0