[iOS & Android] iOS 1.2.3 and Android 1.7.2
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m32s

This commit is contained in:
2025-10-05 23:47:48 -06:00
parent cb20efd58d
commit 3b6c3b5ca2
17 changed files with 302 additions and 125 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 = 29 versionCode = 30
versionName = "1.7.1" versionName = "1.7.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -1,23 +1,18 @@
package com.atridad.openclimb.data.database package com.atridad.openclimb.data.database
import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import android.content.Context
import com.atridad.openclimb.data.database.dao.* import com.atridad.openclimb.data.database.dao.*
import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.model.*
@Database( @Database(
entities = [ entities = [Gym::class, Problem::class, ClimbSession::class, Attempt::class],
Gym::class, version = 6,
Problem::class,
ClimbSession::class,
Attempt::class
],
version = 5,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -29,10 +24,10 @@ abstract class OpenClimbDatabase : RoomDatabase() {
abstract fun attemptDao(): AttemptDao abstract fun attemptDao(): AttemptDao
companion object { companion object {
@Volatile @Volatile private var INSTANCE: OpenClimbDatabase? = null
private var INSTANCE: OpenClimbDatabase? = null
val MIGRATION_4_5 = object : Migration(4, 5) { val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("PRAGMA table_info(climb_sessions)") val cursor = database.query("PRAGMA table_info(climb_sessions)")
val existingColumns = mutableSetOf<String>() val existingColumns = mutableSetOf<String>()
@@ -50,22 +45,50 @@ abstract class OpenClimbDatabase : RoomDatabase() {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT") database.execSQL("ALTER TABLE climb_sessions ADD COLUMN endTime TEXT")
} }
if (!existingColumns.contains("status")) { if (!existingColumns.contains("status")) {
database.execSQL("ALTER TABLE climb_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'COMPLETED'") 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(
database.execSQL("UPDATE climb_sessions SET status = 'COMPLETED' WHERE status IS NULL OR status = ''") "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) { val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
// Add updatedAt column to attempts table
val cursor = database.query("PRAGMA table_info(attempts)")
val existingColumns = mutableSetOf<String>()
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 = ''"
)
}
} }
} }
fun getDatabase(context: Context): OpenClimbDatabase { fun getDatabase(context: Context): OpenClimbDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE
val instance = Room.databaseBuilder( ?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext, context.applicationContext,
OpenClimbDatabase::class.java, OpenClimbDatabase::class.java,
"openclimb_database" "openclimb_database"

View File

@@ -192,7 +192,7 @@ data class BackupAttempt(
val restTime: Long? = null, val restTime: Long? = null,
val timestamp: String, val timestamp: String,
val createdAt: String, val createdAt: String,
val updatedAt: String val updatedAt: String? = null
) { ) {
companion object { companion object {
/** Create BackupAttempt from native Android Attempt model */ /** Create BackupAttempt from native Android Attempt model */
@@ -207,7 +207,8 @@ data class BackupAttempt(
duration = attempt.duration, duration = attempt.duration,
restTime = attempt.restTime, restTime = attempt.restTime,
timestamp = attempt.timestamp, timestamp = attempt.timestamp,
createdAt = attempt.createdAt createdAt = attempt.createdAt,
updatedAt = attempt.updatedAt
) )
} }
} }
@@ -224,7 +225,8 @@ data class BackupAttempt(
duration = duration, duration = duration,
restTime = restTime, restTime = restTime,
timestamp = timestamp, timestamp = timestamp,
createdAt = createdAt createdAt = createdAt,
updatedAt = updatedAt ?: createdAt
) )
} }
} }

View File

@@ -44,7 +44,8 @@ data class Attempt(
val duration: Long? = null, val duration: Long? = null,
val restTime: Long? = null, val restTime: Long? = null,
val timestamp: String, val timestamp: String,
val createdAt: String val createdAt: String,
val updatedAt: String
) { ) {
companion object { companion object {
fun create( fun create(
@@ -68,8 +69,31 @@ data class Attempt(
duration = duration, duration = duration,
restTime = restTime, restTime = restTime,
timestamp = timestamp, 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()
)
}
} }

View File

@@ -74,6 +74,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
private val _isTesting = MutableStateFlow(false) private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow() val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
@@ -91,17 +94,29 @@ class SyncService(private val context: Context, private val repository: ClimbRep
get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: ""
set(value) { set(value) {
sharedPreferences.edit { putString(Keys.SERVER_URL, 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 var authToken: String
get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: ""
set(value) { set(value) {
sharedPreferences.edit { putString(Keys.AUTH_TOKEN, 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 val isConfigured: Boolean
get() = serverURL.isNotEmpty() && authToken.isNotEmpty() get() = serverURL.isNotEmpty() && authToken.isNotEmpty()
private fun updateConfiguredState() {
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
}
var isAutoSyncEnabled: Boolean var isAutoSyncEnabled: Boolean
get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
set(value) { set(value) {
@@ -111,6 +126,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
init { init {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
updateConfiguredState()
repository.setAutoSyncCallback { repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() } kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
@@ -783,6 +799,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_syncError.value = null _syncError.value = null
sharedPreferences.edit().clear().apply() sharedPreferences.edit().clear().apply()
updateConfiguredState()
} }
} }

View File

@@ -204,14 +204,10 @@ fun OpenClimbApp(
) )
) { ) {
showNotificationPermissionDialog = true showNotificationPermissionDialog = true
} else {
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else { } else {
navController.navigate(Screen.AddEditSession()) navController.navigate(Screen.AddEditSession())
} }
} }
}
) )
} else { } else {
null null

View File

@@ -732,6 +732,9 @@ fun AddEditSessionScreen(
LaunchedEffect(gymId, gyms) { LaunchedEffect(gymId, gyms) {
if (gymId != null && selectedGym == null) { if (gymId != null && selectedGym == null) {
selectedGym = gyms.find { it.id == gymId } 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()
} }
} }

View File

@@ -33,6 +33,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
val syncService = viewModel.syncService val syncService = viewModel.syncService
val isSyncing by syncService.isSyncing.collectAsState() val isSyncing by syncService.isSyncing.collectAsState()
val isConnected by syncService.isConnected.collectAsState() val isConnected by syncService.isConnected.collectAsState()
val isConfigured by syncService.isConfiguredFlow.collectAsState()
val isTesting by syncService.isTesting.collectAsState() val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState() val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.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 packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) }
val appVersion = packageInfo.versionName 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 // File picker launcher for import - only accepts ZIP files
val importLauncher = val importLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri
@@ -142,7 +149,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (syncService.isConfigured) { if (isConfigured) {
// Connected state // Connected state
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@@ -730,7 +737,60 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
Spacer(modifier = Modifier.height(16.dp)) 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(
text = "Test connection before enabling sync features", text = "Test connection before enabling sync features",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -747,18 +807,22 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
}, },
confirmButton = { confirmButton = {
Row { Row {
if (syncService.isConfigured) { // Primary action: Save & Test
TextButton( Button(
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
try { try {
// Save configuration first
syncService.serverURL = serverUrl.trim() syncService.serverURL = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection() viewModel.testSyncConnection()
while (syncService.isTesting.value) {
kotlinx.coroutines.delay(100)
}
showSyncConfigDialog = false showSyncConfigDialog = false
} catch (_: Exception) { } catch (e: Exception) {
// Error will be shown via syncError state viewModel.setError(
"Failed to save and test: ${e.message}"
)
} }
} }
}, },
@@ -768,32 +832,55 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
authToken.isNotBlank() authToken.isNotBlank()
) { ) {
if (isTesting) { if (isTesting) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
strokeWidth = 2.dp strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
) )
Spacer(modifier = Modifier.width(8.dp))
Text("Save & Test")
}
} else { } else {
Text("Test Connection") Text("Save & Test")
} }
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
}
// Secondary action: Test only (when already configured)
if (isConfigured) {
TextButton( TextButton(
onClick = { onClick = {
coroutineScope.launch {
try {
syncService.serverURL = serverUrl.trim() syncService.serverURL = serverUrl.trim()
syncService.authToken = authToken.trim() syncService.authToken = authToken.trim()
viewModel.testSyncConnection()
while (syncService.isTesting.value) {
kotlinx.coroutines.delay(100)
}
if (isConnected) {
showSyncConfigDialog = false showSyncConfigDialog = false
}
} catch (e: Exception) {
viewModel.setError(
"Connection test failed: ${e.message}"
)
}
}
}, },
enabled = serverUrl.isNotBlank() && authToken.isNotBlank() enabled =
) { Text("Save") } !isTesting &&
serverUrl.isNotBlank() &&
authToken.isNotBlank()
) { Text("Test Only") }
}
} }
}, },
dismissButton = { dismissButton = {
TextButton( TextButton(
onClick = { onClick = {
// Reset to current values
serverUrl = syncService.serverURL serverUrl = syncService.serverURL
authToken = syncService.authToken authToken = syncService.authToken
showSyncConfigDialog = false showSyncConfigDialog = false

View File

@@ -489,7 +489,8 @@ class BusinessLogicTests {
duration = attempt.duration, duration = attempt.duration,
restTime = attempt.restTime, restTime = attempt.restTime,
timestamp = attempt.timestamp, timestamp = attempt.timestamp,
createdAt = attempt.createdAt createdAt = attempt.createdAt,
updatedAt = attempt.updatedAt,
) )
} }
) )

View File

@@ -230,7 +230,8 @@ class DataModelTests {
duration = 300, duration = 300,
restTime = 120, restTime = 120,
timestamp = "2024-01-01T10:30:00Z", 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) assertEquals("attempt123", attempt.id)
@@ -555,7 +556,8 @@ class DataModelTests {
duration = 120, duration = 120,
restTime = null, restTime = null,
timestamp = "2024-01-01T10:30:00Z", 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 // Verify referential integrity

View File

@@ -73,7 +73,8 @@ class SyncMergeLogicTest {
duration = 300, duration = 300,
restTime = null, restTime = null,
timestamp = "2024-01-01T10:30:00", 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, duration = 180,
restTime = 60, restTime = 60,
timestamp = "2024-01-02T14:30:00", 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 // Add server attempts, preferring newer updates
server.forEach { serverAttempt -> server.forEach { serverAttempt ->
val localAttempt = merged[serverAttempt.id] 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 merged[serverAttempt.id] = serverAttempt
} }

View File

@@ -18,15 +18,16 @@ viewmodel = "2.9.4"
kotlinxSerialization = "1.9.0" kotlinxSerialization = "1.9.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
coil = "2.7.0" coil = "2.7.0"
ksp = "2.2.10-2.0.2" ksp = "2.2.20-2.0.3"
okhttp = "5.1.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
#noinspection SimilarGradleDependency
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } 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-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }

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 = 13; CURRENT_PROJECT_VERSION = 14;
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.2; MARKETING_VERSION = 1.2.3;
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 = 13; CURRENT_PROJECT_VERSION = 14;
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.2; MARKETING_VERSION = 1.2.3;
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 = 13; CURRENT_PROJECT_VERSION = 14;
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.2; MARKETING_VERSION = 1.2.3;
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 = 13; CURRENT_PROJECT_VERSION = 14;
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.2; MARKETING_VERSION = 1.2.3;
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

@@ -336,6 +336,7 @@ struct BackupAttempt: Codable {
let restTime: Int64? // Rest time in seconds let restTime: Int64? // Rest time in seconds
let timestamp: String let timestamp: String
let createdAt: String let createdAt: String
let updatedAt: String?
/// Initialize from native iOS Attempt model /// Initialize from native iOS Attempt model
init(from attempt: Attempt) { init(from attempt: Attempt) {
@@ -352,6 +353,7 @@ struct BackupAttempt: Codable {
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.timestamp = formatter.string(from: attempt.timestamp) self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt) self.createdAt = formatter.string(from: attempt.createdAt)
self.updatedAt = formatter.string(from: attempt.updatedAt)
} }
/// Initialize with explicit parameters for import /// Initialize with explicit parameters for import
@@ -365,7 +367,8 @@ struct BackupAttempt: Codable {
duration: Int64?, duration: Int64?,
restTime: Int64?, restTime: Int64?,
timestamp: String, timestamp: String,
createdAt: String createdAt: String,
updatedAt: String?
) { ) {
self.id = id self.id = id
self.sessionId = sessionId self.sessionId = sessionId
@@ -377,6 +380,7 @@ struct BackupAttempt: Codable {
self.restTime = restTime self.restTime = restTime
self.timestamp = timestamp self.timestamp = timestamp
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt
} }
/// Convert to native iOS Attempt model /// Convert to native iOS Attempt model
@@ -392,6 +396,8 @@ struct BackupAttempt: Codable {
else { else {
throw BackupError.invalidDateFormat throw BackupError.invalidDateFormat
} }
let updatedDateParsed = updatedAt.flatMap { formatter.date(from: $0) }
let updatedDate = updatedDateParsed ?? createdDate
let durationValue = duration.map { Int($0) } let durationValue = duration.map { Int($0) }
let restTimeValue = restTime.map { Int($0) } let restTimeValue = restTime.map { Int($0) }
@@ -406,7 +412,8 @@ struct BackupAttempt: Codable {
duration: durationValue, duration: durationValue,
restTime: restTimeValue, restTime: restTimeValue,
timestamp: timestampDate, timestamp: timestampDate,
createdAt: createdDate createdAt: createdDate,
updatedAt: updatedDate
) )
} }
} }

View File

@@ -474,11 +474,13 @@ struct Attempt: Identifiable, Codable, Hashable {
let restTime: Int? let restTime: Int?
let timestamp: Date let timestamp: Date
let createdAt: Date let createdAt: Date
let updatedAt: Date
init( init(
sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String? = nil,
notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date() notes: String? = nil, duration: Int? = nil, restTime: Int? = nil, timestamp: Date = Date()
) { ) {
let now = Date()
self.id = UUID() self.id = UUID()
self.sessionId = sessionId self.sessionId = sessionId
self.problemId = problemId self.problemId = problemId
@@ -488,7 +490,8 @@ struct Attempt: Identifiable, Codable, Hashable {
self.duration = duration self.duration = duration
self.restTime = restTime self.restTime = restTime
self.timestamp = timestamp self.timestamp = timestamp
self.createdAt = Date() self.createdAt = now
self.updatedAt = now
} }
func updated( func updated(
@@ -506,13 +509,15 @@ struct Attempt: Identifiable, Codable, Hashable {
duration: duration ?? self.duration, duration: duration ?? self.duration,
restTime: restTime ?? self.restTime, restTime: restTime ?? self.restTime,
timestamp: self.timestamp, timestamp: self.timestamp,
createdAt: self.createdAt createdAt: self.createdAt,
updatedAt: Date()
) )
} }
private init( private init(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?, 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.id = id
self.sessionId = sessionId self.sessionId = sessionId
@@ -524,11 +529,13 @@ struct Attempt: Identifiable, Codable, Hashable {
self.restTime = restTime self.restTime = restTime
self.timestamp = timestamp self.timestamp = timestamp
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt
} }
static func fromImport( static func fromImport(
id: UUID, sessionId: UUID, problemId: UUID, result: AttemptResult, highestHold: String?, 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 { ) -> Attempt {
return Attempt( return Attempt(
id: id, id: id,
@@ -540,7 +547,8 @@ struct Attempt: Identifiable, Codable, Hashable {
duration: duration, duration: duration,
restTime: restTime, restTime: restTime,
timestamp: timestamp, timestamp: timestamp,
createdAt: createdAt createdAt: createdAt,
updatedAt: updatedAt
) )
} }
} }

View File

@@ -1,3 +1,3 @@
module openclimb-sync module openclimb-sync
go 1.21 go 1.25