diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 70f872d..4904edf 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..25 + manifest.getInt("schema") in 17..26 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..25) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..26) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index fe64263..a55ef52 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -42,6 +42,11 @@ import com.labfytools.trainlog.model.SyncedProgramDetail import com.labfytools.trainlog.model.SyncedProgramOccurrence import com.labfytools.trainlog.model.SyncedProgramSession import com.labfytools.trainlog.model.SyncedProgramSummary +import com.labfytools.trainlog.model.SleepDiaryDraft +import com.labfytools.trainlog.model.SleepDiaryEntry +import com.labfytools.trainlog.model.SleepDiaryEvent +import com.labfytools.trainlog.model.SleepEventType +import com.labfytools.trainlog.model.SleepQuality import com.labfytools.trainlog.model.ProgramSessionExecutionState import org.json.JSONArray import org.json.JSONObject @@ -466,6 +471,300 @@ class TrainlogRepository( database.close() } + sealed interface SaveSleepDiaryResult { + data class Saved(val entryId: String, val revisionId: String) : SaveSleepDiaryResult + data object Conflict : SaveSleepDiaryResult + data object Invalid : SaveSleepDiaryResult + data object DatabaseError : SaveSleepDiaryResult + } + + sealed interface SleepDiaryImportResult { + data class Applied(val advanced: Int, val unchanged: Int) : SleepDiaryImportResult + data class Rejected(val reason: String) : SleepDiaryImportResult + } + + private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}" + + private fun sleepIdentity(value: String, prefix: String): Boolean = + Regex("^${prefix}_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + .matches(value) + + private fun sleepDraftValid(draft: SleepDiaryDraft): Boolean = try { + val startDate = LocalDate.parse(draft.nightStartDate) + val endDate = LocalDate.parse(draft.nightEndDate) + val created = OffsetDateTime.parse(draft.createdAt).toInstant() + val updated = OffsetDateTime.parse(draft.updatedAt).toInstant() + startDate < endDate && created <= updated && draft.treatmentAndNotes.toByteArray().size <= 16384 && + draft.events.size <= 64 && draft.events.map { it.eventId }.filter { it.isNotEmpty() }.toSet().size == + draft.events.count { it.eventId.isNotEmpty() } && draft.events.all { event -> + val start = OffsetDateTime.parse(event.startAt).toInstant() + if (event.type in setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP, + SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)) event.endAt == null + else event.endAt != null && OffsetDateTime.parse(event.endAt).toInstant() > start + } + } catch (_: RuntimeException) { false } + + /** CONTRACT: one transaction appends an immutable revision and advances + * the guarded tip. Wall time never resolves a concurrent edit. */ + fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult { + if (!sleepDraftValid(draft)) return SaveSleepDiaryResult.Invalid + val creating = draft.entryId == null && draft.expectedRevision == null + if (!creating && (draft.entryId == null || draft.expectedRevision == null)) return SaveSleepDiaryResult.Invalid + val entryId = draft.entryId ?: sleepId("sl") + val revisionId = sleepId("slr") + val db = database.writableDatabase + db.beginTransaction() + return try { + if (creating) { + db.execSQL( + "INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,0)", + arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId), + ) + } else { + val statement = db.compileStatement("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=? WHERE entry_id=? AND current_revision_id=? AND deleted=0") + statement.bindString(1,draft.nightStartDate); statement.bindString(2,draft.nightEndDate) + statement.bindString(3,draft.updatedAt); statement.bindString(4,revisionId) + statement.bindString(5,entryId); statement.bindString(6,checkNotNull(draft.expectedRevision)) + if (statement.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict + } + db.execSQL( + "INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + arrayOf(revisionId,entryId,draft.expectedRevision,draft.updatedAt,draft.sleepQuality?.wireValue, + draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes), + ) + draft.events.forEach { event -> db.execSQL( + "INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", + arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt), + ) } + db.setTransactionSuccessful() + SaveSleepDiaryResult.Saved(entryId,revisionId) + } catch (_: android.database.sqlite.SQLiteConstraintException) { + SaveSleepDiaryResult.Conflict + } catch (_: android.database.sqlite.SQLiteException) { + SaveSleepDiaryResult.DatabaseError + } finally { db.endTransaction() } + } + + fun listSleepDiary(limit: Int = 90): List { + require(limit in 1..3660) + val db = database.readableDatabase + return db.rawQuery("SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id FROM sleep_diary_entries WHERE deleted=0 ORDER BY night_start_date DESC,entry_id LIMIT ?", arrayOf(limit.toString())).use { cursor -> + buildList { + while (cursor.moveToNext()) { + val revision = cursor.getString(5) + val values = db.rawQuery("SELECT sleep_quality,wake_quality,day_form,treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?", arrayOf(revision)).use { detail -> check(detail.moveToFirst()); arrayOf(detail.getString(0),detail.getString(1),detail.getString(2),detail.getString(3)) } + val events = db.rawQuery("SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=? ORDER BY start_at,event_id", arrayOf(revision)).use { eventCursor -> buildList { while (eventCursor.moveToNext()) add(SleepDiaryEvent(eventCursor.getString(0), SleepEventType.entries.single { it.wireValue == eventCursor.getString(1) }, eventCursor.getString(2), if (eventCursor.isNull(3)) null else eventCursor.getString(3))) } } + fun quality(value: String?) = value?.let { text -> SleepQuality.entries.single { it.wireValue == text } } + add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events)) + } + } + } + } + + fun deleteSleepDiary(entryId: String, expectedRevision: String, deletedAt: String): SaveSleepDiaryResult { + val revisionId = sleepId("slr") + val db = database.writableDatabase + db.beginTransaction() + return try { + val current = db.rawQuery( + "SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=? AND deleted=0", + arrayOf(entryId), + ).use { if (it.moveToFirst()) it.getString(0) else null } + if (current != expectedRevision) return SaveSleepDiaryResult.Conflict + val copied = db.compileStatement( + "INSERT INTO sleep_diary_revisions " + + "SELECT ?,entry_id,revision_id,?,sleep_quality,wake_quality,day_form,treatment_and_notes " + + "FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", + ) + copied.bindString(1, revisionId) + copied.bindString(2, deletedAt) + copied.bindString(3, expectedRevision) + copied.bindString(4, entryId) + if (copied.executeInsert() < 0) return SaveSleepDiaryResult.Conflict + db.execSQL( + "INSERT INTO sleep_diary_events SELECT ?,event_id,event_type,start_at,end_at " + + "FROM sleep_diary_events WHERE revision_id=?", + arrayOf(revisionId, expectedRevision), + ) + val guarded = db.compileStatement( + "UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=?,deleted=1 " + + "WHERE entry_id=? AND current_revision_id=? AND deleted=0", + ) + guarded.bindString(1, deletedAt) + guarded.bindString(2, revisionId) + guarded.bindString(3, entryId) + guarded.bindString(4, expectedRevision) + if (guarded.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict + db.setTransactionSuccessful() + SaveSleepDiaryResult.Saved(entryId, revisionId) + } catch (_: android.database.sqlite.SQLiteException) { + SaveSleepDiaryResult.DatabaseError + } finally { + db.endTransaction() + } + } + + /** Complete causal envelope used only inside the full-generation transaction. */ + internal fun buildSleepDiaryV1Json(): String { + val entries = JSONArray() + val db = database.readableDatabase + db.rawQuery( + "SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " + + "FROM sleep_diary_entries ORDER BY entry_id", + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val revisionId = cursor.getString(5) + val revision = db.rawQuery( + "SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes " + + "FROM sleep_diary_revisions WHERE revision_id=?", + arrayOf(revisionId), + ).use { detail -> check(detail.moveToFirst()); arrayOf( + if (detail.isNull(0)) null else detail.getString(0), + if (detail.isNull(1)) null else detail.getString(1), + if (detail.isNull(2)) null else detail.getString(2), + if (detail.isNull(3)) null else detail.getString(3), + if (detail.isNull(4)) "" else detail.getString(4), + ) } + val events = JSONArray() + db.rawQuery( + "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events " + + "WHERE revision_id=? ORDER BY event_id", + arrayOf(revisionId), + ).use { values -> while (values.moveToNext()) events.put( + JSONObject().put("event_id", values.getString(0)) + .put("type", values.getString(1)).put("start_at", values.getString(2)) + .put("end_at", if (values.isNull(3)) JSONObject.NULL else values.getString(3)), + ) } + entries.put(JSONObject().put("entry_id", cursor.getString(0)) + .put("night_start_date", cursor.getString(1)).put("night_end_date", cursor.getString(2)) + .put("created_at", cursor.getString(3)).put("updated_at", cursor.getString(4)) + .put("revision_id", revisionId) + .put("parent_revision_id", revision[0] ?: JSONObject.NULL) + .put("deleted", cursor.getInt(6) != 0) + .put("sleep_quality", revision[1] ?: JSONObject.NULL) + .put("wake_quality", revision[2] ?: JSONObject.NULL) + .put("day_form", revision[3] ?: JSONObject.NULL) + .put("treatment_and_notes", revision[4]).put("events", events)) + } + } + return JSONObject().put("format", "trainlog-sleep-diary").put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()).put("entries", entries).toString() + } + + internal fun applySleepDiaryV1Json(json: String): SleepDiaryImportResult { + val root = try { JSONObject(json) } catch (_: RuntimeException) { + return SleepDiaryImportResult.Rejected("invalid JSON") + } + if (!jsonHasUniqueObjectKeys(json) || root.optString("format") != "trainlog-sleep-diary" || + root.opt("version") != 1 || !root.has("entries")) + return SleepDiaryImportResult.Rejected("invalid envelope") + val values = root.optJSONArray("entries") ?: return SleepDiaryImportResult.Rejected("invalid entries") + if (values.length() > 3660) return SleepDiaryImportResult.Rejected("entry bound exceeded") + val db = database.writableDatabase + var advanced = 0 + var unchanged = 0 + return try { + for (index in 0 until values.length()) { + val item = values.getJSONObject(index) + val entryId = item.getString("entry_id") + val revisionId = item.getString("revision_id") + val parent = if (item.isNull("parent_revision_id")) null else item.getString("parent_revision_id") + if (!sleepIdentity(entryId, "sl") || !sleepIdentity(revisionId, "slr") || + (parent != null && !sleepIdentity(parent, "slr"))) + return SleepDiaryImportResult.Rejected("invalid sleep diary identity") + val local = db.rawQuery( + "SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?", + arrayOf(entryId), + ).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null } + if (local?.first == revisionId) { + val same = db.rawQuery( + "SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," + + "r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r.treatment_and_notes," + + "(SELECT COUNT(*) FROM sleep_diary_events v WHERE v.revision_id=r.revision_id) " + + "FROM sleep_diary_entries e JOIN sleep_diary_revisions r " + + "ON r.revision_id=e.current_revision_id WHERE e.entry_id=?", + arrayOf(entryId), + ).use { current -> + current.moveToFirst() && current.getString(0) == item.getString("night_start_date") && + current.getString(1) == item.getString("night_end_date") && + current.getString(2) == item.getString("created_at") && + current.getString(3) == item.getString("updated_at") && + (current.getInt(4) != 0) == item.getBoolean("deleted") && + (if (current.isNull(5)) null else current.getString(5)) == parent && + (if (current.isNull(6)) null else current.getString(6)) == + (if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) && + (if (current.isNull(7)) null else current.getString(7)) == + (if (item.isNull("wake_quality")) null else item.getString("wake_quality")) && + (if (current.isNull(8)) null else current.getString(8)) == + (if (item.isNull("day_form")) null else item.getString("day_form")) && + current.getString(9).orEmpty() == item.getString("treatment_and_notes") && + current.getInt(10) == item.getJSONArray("events").length() + } + val replayEvents = item.getJSONArray("events") + var sameEvents = same + for (eventIndex in 0 until replayEvents.length()) { + val event = replayEvents.getJSONObject(eventIndex) + sameEvents = sameEvents && db.rawQuery( + "SELECT event_type,start_at,end_at FROM sleep_diary_events " + + "WHERE revision_id=? AND event_id=?", + arrayOf(revisionId, event.getString("event_id")), + ).use { current -> + current.moveToFirst() && current.getString(0) == event.getString("type") && + current.getString(1) == event.getString("start_at") && + (if (current.isNull(2)) null else current.getString(2)) == + (if (event.isNull("end_at")) null else event.getString("end_at")) + } + } + if (!sameEvents) return SleepDiaryImportResult.Rejected( + "sleep diary revision identity reused with different content", + ) + unchanged++ + continue + } + if (local != null && parent != local.first) + return SleepDiaryImportResult.Rejected("concurrent sleep diary revision") + if (local == null && parent != null) + return SleepDiaryImportResult.Rejected("unknown sleep diary parent") + val deleted = item.getBoolean("deleted") + if (local?.second == 1 && !deleted) + return SleepDiaryImportResult.Rejected("sleep diary resurrection") + val draft = SleepDiaryDraft(entryId, parent, item.getString("night_start_date"), + item.getString("night_end_date"), item.getString("created_at"), item.getString("updated_at"), + if (item.isNull("sleep_quality")) null else SleepQuality.entries.single { it.wireValue == item.getString("sleep_quality") }, + if (item.isNull("wake_quality")) null else SleepQuality.entries.single { it.wireValue == item.getString("wake_quality") }, + if (item.isNull("day_form")) null else SleepQuality.entries.single { it.wireValue == item.getString("day_form") }, + item.getString("treatment_and_notes"), buildList { + val events = item.getJSONArray("events") + if (events.length() > 64) throw IllegalArgumentException("event bound") + for (eventIndex in 0 until events.length()) { + val event = events.getJSONObject(eventIndex) + val eventId = event.getString("event_id") + if (!sleepIdentity(eventId, "sle")) + throw IllegalArgumentException("invalid sleep event identity") + add(SleepDiaryEvent(eventId, + SleepEventType.entries.single { it.wireValue == event.getString("type") }, + event.getString("start_at"), + if (event.isNull("end_at")) null else event.getString("end_at"))) + } + }) + if (!sleepDraftValid(draft)) return SleepDiaryImportResult.Rejected("invalid entry") + if (local == null) db.execSQL("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)", + arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0)) + else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?", + arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first)) + db.execSQL("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes)) + draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", + arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) } + advanced++ + } + SleepDiaryImportResult.Applied(advanced, unchanged) + } catch (error: RuntimeException) { + SleepDiaryImportResult.Rejected(error.message ?: "invalid sleep diary") + } + } + /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */ internal fun backupDatabaseTo(destination: File) { require(!destination.exists()) { "Backup snapshot destination already exists." } @@ -511,6 +810,7 @@ class TrainlogRepository( artifacts["feedback"] = buildTrainingFeedbackJson(true) artifacts["causal-deletions"] = buildCausalDeletionExportV1Json() artifacts["program-executions"] = buildProgramExecutionsV1Json() + artifacts["sleep-diary"] = buildSleepDiaryV1Json() artifacts } @@ -8754,7 +9054,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 25, + 26, ) { override fun onConfigure( db: SQLiteDatabase, @@ -8804,6 +9104,7 @@ private class TrainlogDatabaseHelper( createSessionPreparationWithdrawalTable(db) createSyncedProgramTables(db) createProgramExecutionProvenance(db) + createSleepDiaryTables(db) seedEquipment(db) } @@ -9009,6 +9310,14 @@ private class TrainlogDatabaseHelper( createProgramExecutionProvenance(db) version = 25 } + if (version < 26 && newVersion >= 26) { + /* WHY: the phone captures the same minute-precise diary offline. + * CONTRACT: v26 mirrors the versioned sleep-domain structure; it + * does not change completed workout or frozen V1/V3 semantics. + * INVARIANT: migration creates no observation or causal ancestry. */ + createSleepDiaryTables(db) + version = 26 + } if (version != newVersion) { error( @@ -9075,6 +9384,45 @@ private class TrainlogDatabaseHelper( ) } + private fun createSleepDiaryTables(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS sleep_diary_entries( + entry_id TEXT PRIMARY KEY, night_start_date TEXT NOT NULL, + night_end_date TEXT NOT NULL, created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, current_revision_id TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)), + CHECK(entry_id GLOB 'sl_*'), CHECK(night_start_date, +) +data class SleepDiaryDraft( + val entryId: String? = null, val expectedRevision: String? = null, + val nightStartDate: String, val nightEndDate: String, val createdAt: String, + val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, + val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt index 3a135bb..11e12e6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt @@ -43,6 +43,7 @@ private val drawerIcons = mapOf( AppSection.SESSIONS to R.drawable.ic_sessions, AppSection.EXERCISES to R.drawable.ic_exercises, AppSection.EQUIPMENT to R.drawable.ic_equipment, + AppSection.SLEEP to R.drawable.ic_statistics, AppSection.SYNC to R.drawable.ic_sync, AppSection.SETTINGS to R.drawable.ic_settings, ) @@ -60,6 +61,7 @@ fun AndroidAppShell( fun sectionLabel(section: AppSection): String = strings.getString(when (section) { AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment + AppSection.SLEEP -> R.string.nav_sleep AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings }) fun title(): String = strings.getString(when (route) { @@ -73,6 +75,7 @@ fun AndroidAppShell( AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail is AppRoute.EquipmentCreate -> R.string.route_equipment_create AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima + AppRoute.SleepDiary -> R.string.nav_sleep AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings }) val drawerState = rememberDrawerState(DrawerValue.Closed) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index e6b9238..f7290f5 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel enum class AppSection { HOME, SESSIONS, EXERCISES, - EQUIPMENT, SYNC, SETTINGS, + EQUIPMENT, SLEEP, SYNC, SETTINGS, } sealed interface AppRoute { @@ -35,6 +35,7 @@ sealed interface AppRoute { data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT } data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section } data object BodyMeasurements : AppRoute { override val section = AppSection.HOME } + data object SleepDiary : AppRoute { override val section = AppSection.SLEEP } data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES } data object Sync : AppRoute { override val section = AppSection.SYNC } data object Settings : AppRoute { override val section = AppSection.SETTINGS } @@ -45,6 +46,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) { AppSection.SESSIONS -> AppRoute.Sessions AppSection.EXERCISES -> AppRoute.Exercises AppSection.EQUIPMENT -> AppRoute.Equipment + AppSection.SLEEP -> AppRoute.SleepDiary AppSection.SYNC -> AppRoute.Sync AppSection.SETTINGS -> AppRoute.Settings } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt new file mode 100644 index 0000000..d868adf --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt @@ -0,0 +1,151 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.labfytools.trainlog.R +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.SleepDiaryDraft +import com.labfytools.trainlog.model.SleepDiaryEntry +import com.labfytools.trainlog.model.SleepDiaryEvent +import com.labfytools.trainlog.model.SleepEventType +import com.labfytools.trainlog.model.SleepQuality +import java.time.LocalDate +import java.time.OffsetDateTime + +@Composable +fun SleepDiaryScreen(repository: TrainlogRepository) { + var revision by remember { mutableStateOf(0) } + var selected by remember(revision) { mutableStateOf(repository.listSleepDiary().firstOrNull()) } + val today = LocalDate.now() + var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) } + var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) } + var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) } + var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) } + var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) } + var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) } + var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) } + val entries = remember(revision) { repository.listSleepDiary() } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { Text(stringResource(R.string.sleep_current_night)) } + items(entries, key = { it.entryId }) { entry -> + Card(onClick = { selected = entry }) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text("${entry.nightStartDate} → ${entry.nightEndDate}") + Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}") + } + } + } + item { + Card { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.sleep_morning)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(startDate, { startDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) + OutlinedTextField(endDate, { endDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f)) + } + QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it } + QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it } + Text(stringResource(R.string.sleep_day)) + QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it } + SleepEventType.entries.forEach { type -> + Button(onClick = { + val start = OffsetDateTime.now() + events = events + SleepDiaryEvent("", type, start.toString(), + if (type in pointTypes) null else start.plusHours(1).toString()) + }) { Text(eventLabel(type)) } + } + events.forEachIndexed { index, event -> + Card { + Column(Modifier.padding(8.dp)) { + Text(eventLabel(event.type)) + TimeButton(event.startAt) { value -> + events = events.mapIndexed { item, current -> + if (item == index) current.copy(startAt = value) else current + } + } + OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current } }, label = { Text("ISO-8601") }) + event.endAt?.let { end -> + TimeButton(end) { value -> + events = events.mapIndexed { item, current -> + if (item == index) current.copy(endAt = value) else current + } + } + OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current } }, label = { Text("ISO-8601 →") }) + } + Button(onClick = { events = events.filterIndexed { item, _ -> item != index } }) { Text(stringResource(R.string.sleep_delete)) } + } + } + } + OutlinedTextField(notes, { notes = it }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3) + Button(onClick = { + val now = OffsetDateTime.now().toString() + val current = selected + val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events)) + if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } } + }) { Text(stringResource(R.string.sleep_save)) } + selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } } + } + } + } + } +} + +@Composable +private fun TimeButton(value: String, change: (String) -> Unit) { + val context = LocalContext.current + val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull() + Button(onClick = { + val current = parsed ?: OffsetDateTime.now() + android.app.TimePickerDialog(context, { _, hour, minute -> + change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString()) + }, current.hour, current.minute, true).show() + }) { + Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—")) + } +} + +private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP, + SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS) + +@Composable +private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) { + Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) } + } } +} + +@Composable +private fun eventLabel(type: SleepEventType): String = stringResource(when (type) { + SleepEventType.BED_TIME -> R.string.sleep_bed_time + SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up + SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up + SleepEventType.SLEEP -> R.string.sleep_sleep + SleepEventType.NAP -> R.string.sleep_nap + SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake + SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep + SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness +}) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 8636fc4..14e31e8 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -140,6 +140,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) } is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() } AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() }) + AppRoute.SleepDiary -> SleepDiaryScreen(repository) AppRoute.LatestMaxima -> LatestMaximaScreen(repository) AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() }) AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 683de90..01b8977 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -8,6 +8,26 @@ Statistics Synchronization Settings + Sleep + Current night + Morning + Day + Bedtime + Final get-up + Night get-up + Sleep + Nap + Long awakening + Half-sleep + Sleepiness + Sleep quality + Wake quality + Day form + Treatment and notes + Add event + Save + Delete + Choose time Current session Plan a session Drafts diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bfc9fce..d11f463 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,6 +8,26 @@ Statistiques Synchronisation Paramètres + Sommeil + Nuit actuelle + Matin + Journée + Mise au lit + Lever + Lever nocturne + Sommeil + Sieste + Long réveil + Demi-sommeil + Somnolence + Qualité du sommeil + Qualité du réveil + Forme de la journée + Traitement et remarques + Ajouter un événement + Enregistrer + Supprimer + Choisir l’heure Séance en cours Programmer une séance Brouillons diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 7b3423a..4062a25 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(25, scalar(db, "PRAGMA user_version")) + assertEquals(26, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 435ae91..72fcf63 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(25, scalar(db, "PRAGMA user_version")) + assertEquals(26, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 5bdbc76..04968e7 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(25, scalar(db, "PRAGMA user_version")) + assertEquals(26, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index 7f81195..a1673aa 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(25, scalar(db, "PRAGMA user_version")) + assertEquals(26, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt new file mode 100644 index 0000000..540a248 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -0,0 +1,50 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV26SleepMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v25AddsEmptySleepDomainAndReopens() { + val name = "sleep-v25-${UUID.randomUUID()}.db" + TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) } + SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("DROP TABLE sleep_diary_events") + db.execSQL("DROP TABLE sleep_diary_revisions") + db.execSQL("DROP TABLE sleep_diary_entries") + db.version = 25 + } + var repository = TrainlogRepository(context, name) + try { + assertTrueEmpty(repository) + repository.close() + repository = TrainlogRepository(context, name) + assertTrueEmpty(repository) + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { assertEquals(26, it.version) } + } finally { + repository.close() + context.deleteDatabase(name) + } + } + + private fun assertTrueEmpty(repository: TrainlogRepository) = assertEquals(emptyList(), repository.listSleepDiary()) + + private fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) { + try { block(this) } finally { close() } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 3d58114..d3d57a4 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt index fb3c822..7da55de 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BridgeBackupMigrationTest.kt @@ -43,7 +43,7 @@ class BridgeBackupMigrationTest { null, android.database.sqlite.SQLiteDatabase.OPEN_READONLY, ) - .use { assertEquals(25, it.version) } + .use { assertEquals(26, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index a016563..53b5fe6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -260,7 +260,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("25", scalarText(name, "PRAGMA user_version")) + assertEquals("26", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt new file mode 100644 index 0000000..47359dd --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -0,0 +1,49 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.SleepDiaryDraft +import com.labfytools.trainlog.model.SleepDiaryEvent +import com.labfytools.trainlog.model.SleepEventType +import com.labfytools.trainlog.model.SleepQuality +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SleepDiaryRepositoryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test fun createEditConflictDeleteAndReopen() { + val name = "sleep-${UUID.randomUUID()}.db" + var repository = TrainlogRepository(context, name) + try { + val initial = SleepDiaryDraft(nightStartDate = "2026-10-24", nightEndDate = "2026-10-25", + createdAt = "2026-10-24T23:30:00+02:00", updatedAt = "2026-10-24T23:30:00+02:00", + sleepQuality = SleepQuality.B, wakeQuality = null, dayForm = null, + treatmentAndNotes = "Synthetic fixture", events = listOf( + SleepDiaryEvent("", SleepEventType.SLEEP, "2026-10-24T23:30:00+02:00", "2026-10-25T06:30:00+01:00"), + SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null))) + val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved + val loaded = repository.listSleepDiary().single() + assertEquals(2, loaded.events.size) + val edited = repository.saveSleepDiary(initial.copy(entryId = created.entryId, + expectedRevision = created.revisionId, updatedAt = "2026-10-25T18:00:00+01:00", + wakeQuality = SleepQuality.MOY, dayForm = SleepQuality.TB)) + assertTrue(edited is TrainlogRepository.SaveSleepDiaryResult.Saved) + assertTrue(repository.saveSleepDiary(initial.copy(entryId = created.entryId, + expectedRevision = created.revisionId)) is TrainlogRepository.SaveSleepDiaryResult.Conflict) + repository.close(); repository = TrainlogRepository(context, name) + val reopened = repository.listSleepDiary().single() + assertEquals(SleepQuality.MOY, reopened.wakeQuality) + assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId, + "2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved) + assertTrue(repository.listSleepDiary().isEmpty()) + } finally { repository.close(); context.deleteDatabase(name) } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 7cf221c..b22d772 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -506,7 +506,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(25, it.getInt(0)) + assertEquals(26, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 36f1652..d62c5f3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(25, cursor.getInt(0)) + assertEquals(26, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(25, cursor.getInt(0)) + assertEquals(26, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt index b0b81ae..470ac53 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt @@ -37,7 +37,7 @@ class AppNavigationStateTest { @After fun tearDown() { context.deleteDatabase(databaseName) } @Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() { - assertEquals(6, AppSection.entries.size) + assertEquals(7, AppSection.entries.size) assertFalse(AppSection.entries.any { it.name == "STATISTICS" }) AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) } assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)