[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
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m32s
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -489,7 +489,8 @@ class BusinessLogicTests {
|
||||
duration = attempt.duration,
|
||||
restTime = attempt.restTime,
|
||||
timestamp = attempt.timestamp,
|
||||
createdAt = attempt.createdAt
|
||||
createdAt = attempt.createdAt,
|
||||
updatedAt = attempt.updatedAt,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module openclimb-sync
|
||||
|
||||
go 1.21
|
||||
go 1.25
|
||||
|
||||
Reference in New Issue
Block a user