[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"
minSdk = 31
targetSdk = 36
versionCode = 29
versionName = "1.7.1"
versionCode = 30
versionName = "1.7.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -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<String>()
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<String>()
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<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 = ''"
)
}
}
}
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
}
}
}
}

View File

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

View File

@@ -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()
)
}
}

View File

@@ -74,6 +74,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConfigured = MutableStateFlow(false)
val isConfiguredFlow: StateFlow<Boolean> = _isConfigured.asStateFlow()
private val _isTesting = MutableStateFlow(false)
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, "") ?: ""
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()
}
}

View File

@@ -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())
}
}
)

View File

@@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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