feat(android): add sleep diary capture
This commit is contained in:
parent
36a7e651ac
commit
bf27e05e7a
21 changed files with 680 additions and 15 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<SleepDiaryEntry> {
|
||||
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<night_end_date)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS sleep_diary_revisions(
|
||||
revision_id TEXT PRIMARY KEY,
|
||||
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
|
||||
parent_revision_id TEXT, created_at TEXT NOT NULL,
|
||||
sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),
|
||||
wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),
|
||||
day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),
|
||||
treatment_and_notes TEXT,
|
||||
CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),
|
||||
UNIQUE(entry_id,revision_id)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS sleep_diary_events(
|
||||
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
|
||||
event_id TEXT NOT NULL, event_type TEXT NOT NULL CHECK(event_type IN(
|
||||
'bed_time','final_get_up','night_get_up','sleep','nap','long_awake',
|
||||
'half_sleep','daytime_sleepiness')),
|
||||
start_at TEXT NOT NULL, end_at TEXT,
|
||||
CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND end_at IS NULL)
|
||||
OR (event_type IN('sleep','nap','long_awake','half_sleep') AND end_at IS NOT NULL)),
|
||||
PRIMARY KEY(revision_id,event_id)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at)")
|
||||
}
|
||||
|
||||
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
|
||||
fun addColumn(table: String, name: String, declaration: String) {
|
||||
if (!tableHasColumn(db, table, name)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.labfytools.trainlog.model
|
||||
|
||||
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
|
||||
enum class SleepEventType(val wireValue: String) {
|
||||
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
|
||||
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
|
||||
DAYTIME_SLEEPINESS("daytime_sleepiness"),
|
||||
}
|
||||
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
|
||||
data class SleepDiaryEntry(
|
||||
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
||||
val createdAt: String, val updatedAt: String, val revisionId: String,
|
||||
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
|
||||
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
|
||||
)
|
||||
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<SleepDiaryEvent>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SleepDiaryEntry?>(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
|
||||
})
|
||||
|
|
@ -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++ }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,26 @@
|
|||
<string name="nav_statistics">Statistics</string>
|
||||
<string name="nav_sync">Synchronization</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_sleep">Sleep</string>
|
||||
<string name="sleep_current_night">Current night</string>
|
||||
<string name="sleep_morning">Morning</string>
|
||||
<string name="sleep_day">Day</string>
|
||||
<string name="sleep_bed_time">Bedtime</string>
|
||||
<string name="sleep_final_get_up">Final get-up</string>
|
||||
<string name="sleep_night_get_up">Night get-up</string>
|
||||
<string name="sleep_sleep">Sleep</string>
|
||||
<string name="sleep_nap">Nap</string>
|
||||
<string name="sleep_long_awake">Long awakening</string>
|
||||
<string name="sleep_half_sleep">Half-sleep</string>
|
||||
<string name="sleep_sleepiness">Sleepiness</string>
|
||||
<string name="sleep_quality">Sleep quality</string>
|
||||
<string name="sleep_wake_quality">Wake quality</string>
|
||||
<string name="sleep_day_form">Day form</string>
|
||||
<string name="sleep_notes">Treatment and notes</string>
|
||||
<string name="sleep_add_event">Add event</string>
|
||||
<string name="sleep_save">Save</string>
|
||||
<string name="sleep_delete">Delete</string>
|
||||
<string name="sleep_choose_time">Choose time</string>
|
||||
<string name="route_session_editor">Current session</string>
|
||||
<string name="route_session_generator">Plan a session</string>
|
||||
<string name="route_ai_drafts">Drafts</string>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,26 @@
|
|||
<string name="nav_statistics">Statistiques</string>
|
||||
<string name="nav_sync">Synchronisation</string>
|
||||
<string name="nav_settings">Paramètres</string>
|
||||
<string name="nav_sleep">Sommeil</string>
|
||||
<string name="sleep_current_night">Nuit actuelle</string>
|
||||
<string name="sleep_morning">Matin</string>
|
||||
<string name="sleep_day">Journée</string>
|
||||
<string name="sleep_bed_time">Mise au lit</string>
|
||||
<string name="sleep_final_get_up">Lever</string>
|
||||
<string name="sleep_night_get_up">Lever nocturne</string>
|
||||
<string name="sleep_sleep">Sommeil</string>
|
||||
<string name="sleep_nap">Sieste</string>
|
||||
<string name="sleep_long_awake">Long réveil</string>
|
||||
<string name="sleep_half_sleep">Demi-sommeil</string>
|
||||
<string name="sleep_sleepiness">Somnolence</string>
|
||||
<string name="sleep_quality">Qualité du sommeil</string>
|
||||
<string name="sleep_wake_quality">Qualité du réveil</string>
|
||||
<string name="sleep_day_form">Forme de la journée</string>
|
||||
<string name="sleep_notes">Traitement et remarques</string>
|
||||
<string name="sleep_add_event">Ajouter un événement</string>
|
||||
<string name="sleep_save">Enregistrer</string>
|
||||
<string name="sleep_delete">Supprimer</string>
|
||||
<string name="sleep_choose_time">Choisir l’heure</string>
|
||||
<string name="route_session_editor">Séance en cours</string>
|
||||
<string name="route_session_generator">Programmer une séance</string>
|
||||
<string name="route_ai_drafts">Brouillons</string>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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<Any>(), repository.listSleepDiary())
|
||||
|
||||
private fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) {
|
||||
try { block(this) } finally { close() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue