diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2033726..a6124ac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 29 - versionName = "1.7.1" + versionCode = 30 + versionName = "1.7.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt b/android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt index c53c35d..852988b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/database/OpenClimbDatabase.kt @@ -1,82 +1,105 @@ package com.atridad.openclimb.data.database +import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import android.content.Context import com.atridad.openclimb.data.database.dao.* import com.atridad.openclimb.data.model.* @Database( - entities = [ - Gym::class, - Problem::class, - ClimbSession::class, - Attempt::class - ], - version = 5, - exportSchema = false + entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class], + version = 6, + exportSchema = false ) @TypeConverters(Converters::class) abstract class OpenClimbDatabase : RoomDatabase() { - + abstract fun gymDao(): GymDao abstract fun problemDao(): ProblemDao abstract fun climbSessionDao(): ClimbSessionDao abstract fun attemptDao(): AttemptDao - + companion object { - @Volatile - private var INSTANCE: OpenClimbDatabase? = null - - val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(database: SupportSQLiteDatabase) { - val cursor = database.query("PRAGMA table_info(climb_sessions)") - val existingColumns = mutableSetOf() - - while (cursor.moveToNext()) { - val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name")) - existingColumns.add(columnName) + @Volatile private var INSTANCE: OpenClimbDatabase? = null + + val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + val cursor = database.query("PRAGMA table_info(climb_sessions)") + val existingColumns = mutableSetOf() + + while (cursor.moveToNext()) { + val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name")) + existingColumns.add(columnName) + } + cursor.close() + + if (!existingColumns.contains("startTime")) { + database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") + } + if (!existingColumns.contains("endTime")) { + database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") + } + if (!existingColumns.contains("status")) { + database.execSQL( + "ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'" + ) + } + + database.execSQL( + "UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL" + ) + database.execSQL( + "UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''" + ) + } } - cursor.close() - - if (!existingColumns.contains("startTime")) { - database.execSQL("ALTER TABLE climb_sessions ADD COLUMN startTime TEXT") + + val MIGRATION_5_6 = + object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add updatedAt column to attempts table + val cursor = database.query("PRAGMA table_info(attempts)") + val existingColumns = mutableSetOf() + + while (cursor.moveToNext()) { + val columnName = cursor.getString(cursor.getColumnIndexOrThrow("name")) + existingColumns.add(columnName) + } + cursor.close() + + if (!existingColumns.contains("updatedAt")) { + database.execSQL( + "ALTER TABLE attempts ADD COLUMN updatedAt TEXT NOT NULL DEFAULT ''" + ) + // Set updatedAt to createdAt for existing records + database.execSQL( + "UPDATE attempts SET updatedAt = createdAt WHERE updatedAt = ''" + ) + } + } } - if (!existingColumns.contains("endTime")) { - database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") - } - if (!existingColumns.contains("status")) { - database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'") - } - - database.execSQL("UPDATE climb_sessions SET startTime = createdAt WHERE startTime IS NULL") - database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''") - } - } - - val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(database: SupportSQLiteDatabase) { - } - } - + fun getDatabase(context: Context): OpenClimbDatabase { - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - OpenClimbDatabase::class.java, - "openclimb_database" - ) - .addMigrations(MIGRATION_4_5, MIGRATION_5_6) - .enableMultiInstanceInvalidation() - .fallbackToDestructiveMigration() - .build() - INSTANCE = instance - instance - } + return INSTANCE + ?: synchronized(this) { + val instance = + Room.databaseBuilder( + context.applicationContext, + OpenClimbDatabase::class.java, + "openclimb_database" + ) + .addMigrations(MIGRATION_4_5, MIGRATION_5_6) + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } } } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt index d97f448..a7a0b19 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -192,7 +192,7 @@ data class BackupAttempt( val restTime: Long? = null, val timestamp: String, val createdAt: String, - val updatedAt: String + val updatedAt: String? = null ) { companion object { /** Create BackupAttempt from native Android Attempt model */ @@ -207,7 +207,8 @@ data class BackupAttempt( duration = attempt.duration, restTime = attempt.restTime, timestamp = attempt.timestamp, - createdAt = attempt.createdAt + createdAt = attempt.createdAt, + updatedAt = attempt.updatedAt ) } } @@ -224,7 +225,8 @@ data class BackupAttempt( duration = duration, restTime = restTime, timestamp = timestamp, - createdAt = createdAt + createdAt = createdAt, + updatedAt = updatedAt ?: createdAt ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt index 0e35efa..2ebda6d 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt @@ -44,7 +44,8 @@ data class Attempt( val duration: Long? = null, val restTime: Long? = null, val timestamp: String, - val createdAt: String + val createdAt: String, + val updatedAt: String ) { companion object { fun create( @@ -68,8 +69,31 @@ data class Attempt( duration = duration, restTime = restTime, timestamp = timestamp, - createdAt = now + createdAt = now, + updatedAt = now ) } } + + fun updated( + result: AttemptResult? = null, + highestHold: String? = null, + notes: String? = null, + duration: Long? = null, + restTime: Long? = null + ): Attempt { + return Attempt( + id = this.id, + sessionId = this.sessionId, + problemId = this.problemId, + result = result ?: this.result, + highestHold = highestHold ?: this.highestHold, + notes = notes ?: this.notes, + duration = duration ?: this.duration, + restTime = restTime ?: this.restTime, + timestamp = this.timestamp, + createdAt = this.createdAt, + updatedAt = DateFormatUtils.nowISO8601() + ) + } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 7e08d93..3619d49 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -74,6 +74,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private val _isConfigured = MutableStateFlow(false) + val isConfiguredFlow: StateFlow = _isConfigured.asStateFlow() + private val _isTesting = MutableStateFlow(false) val isTesting: StateFlow = _isTesting.asStateFlow() @@ -91,17 +94,29 @@ class SyncService(private val context: Context, private val repository: ClimbRep get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" set(value) { sharedPreferences.edit { putString(Keys.SERVER_URL, value) } + updateConfiguredState() + // Clear connection status when configuration changes + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() } var authToken: String get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" set(value) { sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) } + updateConfiguredState() + // Clear connection status when configuration changes + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() } val isConfigured: Boolean get() = serverURL.isNotEmpty() && authToken.isNotEmpty() + private fun updateConfiguredState() { + _isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty() + } + var isAutoSyncEnabled: Boolean get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) set(value) { @@ -111,6 +126,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep init { _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) + updateConfiguredState() repository.setAutoSyncCallback { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() } @@ -783,6 +799,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep _syncError.value = null sharedPreferences.edit().clear().apply() + updateConfiguredState() } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt index 1e42333..c6a4e33 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -205,11 +205,7 @@ fun OpenClimbApp( ) { showNotificationPermissionDialog = true } else { - if (gyms.size == 1) { - viewModel.startSession(context, gyms.first().id) - } else { - navController.navigate(Screen.AddEditSession()) - } + navController.navigate(Screen.AddEditSession()) } } ) diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt index 8fea3d0..3705ea4 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/AddEditScreens.kt @@ -732,6 +732,9 @@ fun AddEditSessionScreen( LaunchedEffect(gymId, gyms) { if (gymId != null && selectedGym == null) { selectedGym = gyms.find { it.id == gymId } + } else if (gymId == null && selectedGym == null && gyms.size == 1) { + // Auto-select the gym if there's only one available + selectedGym = gyms.first() } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index ec31063..78a6f92 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -33,6 +33,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) { val syncService = viewModel.syncService val isSyncing by syncService.isSyncing.collectAsState() val isConnected by syncService.isConnected.collectAsState() + val isConfigured by syncService.isConfiguredFlow.collectAsState() val isTesting by syncService.isTesting.collectAsState() val lastSyncTime by syncService.lastSyncTime.collectAsState() val syncError by syncService.syncError.collectAsState() @@ -49,6 +50,12 @@ fun SettingsScreen(viewModel: ClimbViewModel) { val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val appVersion = packageInfo.versionName + // Update local state when sync service configuration changes + LaunchedEffect(syncService.serverURL, syncService.authToken) { + serverUrl = syncService.serverURL + authToken = syncService.authToken + } + // File picker launcher for import - only accepts ZIP files val importLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri @@ -142,7 +149,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) { Spacer(modifier = Modifier.height(12.dp)) - if (syncService.isConfigured) { + if (isConfigured) { // Connected state Card( shape = RoundedCornerShape(12.dp), @@ -730,7 +737,60 @@ fun SettingsScreen(viewModel: ClimbViewModel) { Spacer(modifier = Modifier.height(16.dp)) - if (syncService.isConfigured) { + // Connection status indicator + if (isTesting) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Testing connection...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } else if (isConnected && isConfigured) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Connection successful", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } else if (syncError != null && isConfigured) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Connection failed: $syncError", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } else if (isConfigured) { Text( text = "Test connection before enabling sync features", style = MaterialTheme.typography.bodySmall, @@ -747,18 +807,66 @@ fun SettingsScreen(viewModel: ClimbViewModel) { }, confirmButton = { Row { - if (syncService.isConfigured) { + // Primary action: Save & Test + Button( + onClick = { + coroutineScope.launch { + try { + syncService.serverURL = serverUrl.trim() + syncService.authToken = authToken.trim() + viewModel.testSyncConnection() + while (syncService.isTesting.value) { + kotlinx.coroutines.delay(100) + } + showSyncConfigDialog = false + } catch (e: Exception) { + viewModel.setError( + "Failed to save and test: ${e.message}" + ) + } + } + }, + enabled = + !isTesting && + serverUrl.isNotBlank() && + authToken.isNotBlank() + ) { + if (isTesting) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Save & Test") + } + } else { + Text("Save & Test") + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Secondary action: Test only (when already configured) + if (isConfigured) { TextButton( onClick = { coroutineScope.launch { try { - // Save configuration first syncService.serverURL = serverUrl.trim() syncService.authToken = authToken.trim() viewModel.testSyncConnection() - showSyncConfigDialog = false - } catch (_: Exception) { - // Error will be shown via syncError state + while (syncService.isTesting.value) { + kotlinx.coroutines.delay(100) + } + if (isConnected) { + showSyncConfigDialog = false + } + } catch (e: Exception) { + viewModel.setError( + "Connection test failed: ${e.message}" + ) } } }, @@ -766,34 +874,13 @@ fun SettingsScreen(viewModel: ClimbViewModel) { !isTesting && serverUrl.isNotBlank() && authToken.isNotBlank() - ) { - if (isTesting) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Test Connection") - } - } - - Spacer(modifier = Modifier.width(8.dp)) + ) { Text("Test Only") } } - - TextButton( - onClick = { - syncService.serverURL = serverUrl.trim() - syncService.authToken = authToken.trim() - showSyncConfigDialog = false - }, - enabled = serverUrl.isNotBlank() && authToken.isNotBlank() - ) { Text("Save") } } }, dismissButton = { TextButton( onClick = { - // Reset to current values serverUrl = syncService.serverURL authToken = syncService.authToken showSyncConfigDialog = false diff --git a/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt b/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt index c38ac36..7c2352d 100644 --- a/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt +++ b/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt @@ -489,7 +489,8 @@ class BusinessLogicTests { duration = attempt.duration, restTime = attempt.restTime, timestamp = attempt.timestamp, - createdAt = attempt.createdAt + createdAt = attempt.createdAt, + updatedAt = attempt.updatedAt, ) } ) diff --git a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt index 8e70402..5c4b528 100644 --- a/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt +++ b/android/app/src/test/java/com/atridad/openclimb/DataModelTests.kt @@ -230,7 +230,8 @@ class DataModelTests { duration = 300, restTime = 120, timestamp = "2024-01-01T10:30:00Z", - createdAt = "2024-01-01T10:30:00Z" + createdAt = "2024-01-01T10:30:00Z", + updatedAt = "2024-01-01T10:30:00Z" ) assertEquals("attempt123", attempt.id) @@ -555,7 +556,8 @@ class DataModelTests { duration = 120, restTime = null, timestamp = "2024-01-01T10:30:00Z", - createdAt = "2024-01-01T10:30:00Z" + createdAt = "2024-01-01T10:30:00Z", + updatedAt = "2024-01-01T10:30:00Z" ) // Verify referential integrity diff --git a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt index eff31fb..27699f2 100644 --- a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt +++ b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt @@ -73,7 +73,8 @@ class SyncMergeLogicTest { duration = 300, restTime = null, timestamp = "2024-01-01T10:30:00", - createdAt = "2024-01-01T10:30:00" + createdAt = "2024-01-01T10:30:00", + updatedAt = "2024-01-01T10:30:00" ) ) @@ -186,7 +187,8 @@ class SyncMergeLogicTest { duration = 180, restTime = 60, timestamp = "2024-01-02T14:30:00", - createdAt = "2024-01-02T14:30:00" + createdAt = "2024-01-02T14:30:00", + updatedAt = "2024-01-02T14:30:00" ) ) @@ -428,7 +430,11 @@ class SyncMergeLogicTest { // Add server attempts, preferring newer updates server.forEach { serverAttempt -> val localAttempt = merged[serverAttempt.id] - if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) + if (localAttempt == null || + isNewerThan( + serverAttempt.updatedAt ?: serverAttempt.createdAt, + localAttempt.updatedAt ?: localAttempt.createdAt + ) ) { merged[serverAttempt.id] = serverAttempt } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b4f55cf..84d3273 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -18,15 +18,16 @@ viewmodel = "2.9.4" kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" -ksp = "2.2.10-2.0.2" -okhttp = "5.1.0" +ksp = "2.2.20-2.0.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } +#noinspection SimilarGradleDependency androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +#noinspection SimilarGradleDependency androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 1deee67..09f6bcc 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -485,7 +485,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -508,7 +508,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -528,7 +528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -603,7 +603,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -622,7 +622,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -633,7 +633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index f619bc0..9425177 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift index 0cacb0f..577f4ac 100644 --- a/ios/OpenClimb/Models/BackupFormat.swift +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -336,6 +336,7 @@ struct BackupAttempt: Codable { let restTime: Int64? // Rest time in seconds let timestamp: String let createdAt: String + let updatedAt: String? /// Initialize from native iOS Attempt model init(from attempt: Attempt) { @@ -352,6 +353,7 @@ struct BackupAttempt: Codable { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.timestamp = formatter.string(from: attempt.timestamp) self.createdAt = formatter.string(from: attempt.createdAt) + self.updatedAt = formatter.string(from: attempt.updatedAt) } /// Initialize with explicit parameters for import @@ -365,7 +367,8 @@ struct BackupAttempt: Codable { duration: Int64?, restTime: Int64?, timestamp: String, - createdAt: String + createdAt: String, + updatedAt: String? ) { self.id = id self.sessionId = sessionId @@ -377,6 +380,7 @@ struct BackupAttempt: Codable { self.restTime = restTime self.timestamp = timestamp self.createdAt = createdAt + self.updatedAt = updatedAt } /// Convert to native iOS Attempt model @@ -385,13 +389,15 @@ struct BackupAttempt: Codable { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), - let sessionUuid = UUID(uuidString: sessionId), - let problemUuid = UUID(uuidString: problemId), - let timestampDate = formatter.date(from: timestamp), - let createdDate = formatter.date(from: createdAt) + let sessionUuid = UUID(uuidString: sessionId), + let problemUuid = UUID(uuidString: problemId), + let timestampDate = formatter.date(from: timestamp), + let createdDate = formatter.date(from: createdAt) else { throw BackupError.invalidDateFormat } + let updatedDateParsed = updatedAt.flatMap { formatter.date(from: $0) } + let updatedDate = updatedDateParsed ?? createdDate let durationValue = duration.map { Int($0) } let restTimeValue = restTime.map { Int($0) } @@ -406,7 +412,8 @@ struct BackupAttempt: Codable { duration: durationValue, restTime: restTimeValue, timestamp: timestampDate, - createdAt: createdDate + createdAt: createdDate, + updatedAt: updatedDate ) } } diff --git a/ios/OpenClimb/Models/DataModels.swift b/ios/OpenClimb/Models/DataModels.swift index a514538..6a4a30f 100644 --- a/ios/OpenClimb/Models/DataModels.swift +++ b/ios/OpenClimb/Models/DataModels.swift @@ -474,11 +474,13 @@ struct Attempt: Identifiable, Codable, Hashable { let restTime: Int? let timestamp: Date let createdAt: Date + let updatedAt: Date init( sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil, notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date() ) { + let now = Date() self.id = UUID() self.sessionId = sessionId self.problemId = problemId @@ -488,7 +490,8 @@ struct Attempt: Identifiable, Codable, Hashable { self.duration = duration self.restTime = restTime self.timestamp = timestamp - self.createdAt = Date() + self.createdAt = now + self.updatedAt = now } func updated( @@ -506,13 +509,15 @@ struct Attempt: Identifiable, Codable, Hashable { duration: duration ?? self.duration, restTime: restTime ?? self.restTime, timestamp: self.timestamp, - createdAt: self.createdAt + createdAt: self.createdAt, + updatedAt: Date() ) } private init( id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?, - notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date + notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date, + updatedAt: Date ) { self.id = id self.sessionId = sessionId @@ -524,11 +529,13 @@ struct Attempt: Identifiable, Codable, Hashable { self.restTime = restTime self.timestamp = timestamp self.createdAt = createdAt + self.updatedAt = updatedAt } static func fromImport( id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?, - notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date + notes: String?, duration: Int?, restTime: Int?, timestamp: Date, createdAt: Date, + updatedAt: Date ) -> Attempt { return Attempt( id: id, @@ -540,7 +547,8 @@ struct Attempt: Identifiable, Codable, Hashable { duration: duration, restTime: restTime, timestamp: timestamp, - createdAt: createdAt + createdAt: createdAt, + updatedAt: updatedAt ) } } diff --git a/sync/go.mod b/sync/go.mod index 44618d1..3103696 100644 --- a/sync/go.mod +++ b/sync/go.mod @@ -1,3 +1,3 @@ module openclimb-sync -go 1.21 +go 1.25