feat(android): add one-tap sleep capture
This commit is contained in:
parent
dd39c85f94
commit
070a29298f
10 changed files with 739 additions and 4 deletions
|
|
@ -31,6 +31,12 @@
|
|||
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
|
||||
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
||||
V1 companion imports idempotently into desktop schema v31.
|
||||
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
|
||||
Couché, active medication buttons, point Réveil events and Levé each record
|
||||
the current timestamp in one action. A guarded short-lived Annuler creates a
|
||||
causal compensating revision, while Corriger la nuit retains the complete
|
||||
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
|
||||
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
||||
|
|
|
|||
|
|
@ -277,9 +277,15 @@ class HeartRateSensorService : Service() {
|
|||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||
HeartRateLiveState.measurement(parsed.measurement)
|
||||
repository().recordLiveHeartRateForActiveSession(
|
||||
val observedAt = OffsetDateTime.now().toString()
|
||||
val repository = repository()
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
sensorName = selected?.name,
|
||||
observedAt = OffsetDateTime.now().toString(),
|
||||
observedAt = observedAt,
|
||||
measurement = parsed.measurement,
|
||||
) ?: repository.recordLiveHeartRateForActiveSleep(
|
||||
sensorName = selected?.name,
|
||||
observedAt = observedAt,
|
||||
measurement = parsed.measurement,
|
||||
)
|
||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||
|
|
|
|||
|
|
@ -492,6 +492,21 @@ class TrainlogRepository(
|
|||
data class Rejected(val reason: String) : SleepDiaryImportResult
|
||||
}
|
||||
|
||||
data class SleepQuickActionReceipt(
|
||||
val entryId: String,
|
||||
val previousRevisionId: String?,
|
||||
val appliedRevisionId: String,
|
||||
)
|
||||
|
||||
sealed interface SleepQuickActionResult {
|
||||
data class Applied(val receipt: SleepQuickActionReceipt) : SleepQuickActionResult
|
||||
data object NoActiveNight : SleepQuickActionResult
|
||||
data object AlreadyActive : SleepQuickActionResult
|
||||
data object InvalidMedication : SleepQuickActionResult
|
||||
data object Conflict : SleepQuickActionResult
|
||||
data object Error : SleepQuickActionResult
|
||||
}
|
||||
|
||||
sealed interface StartHeartRateCaptureResult {
|
||||
data class Started(val captureId: String) : StartHeartRateCaptureResult
|
||||
data object Invalid : StartHeartRateCaptureResult
|
||||
|
|
@ -668,6 +683,257 @@ class TrainlogRepository(
|
|||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
private fun activeSleepDiaryEntry(): SleepDiaryEntry? =
|
||||
listSleepDiary().firstOrNull { entry ->
|
||||
entry.events.any { it.type == SleepEventType.BED_TIME } &&
|
||||
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
||||
}
|
||||
|
||||
private fun quickSleepDates(at: String): Pair<String, String>? =
|
||||
runCatching {
|
||||
val moment = OffsetDateTime.parse(at)
|
||||
val start = if (moment.hour >= 18) moment.toLocalDate() else moment.toLocalDate().minusDays(1)
|
||||
start.toString() to start.plusDays(1).toString()
|
||||
}.getOrNull()
|
||||
|
||||
fun quickSleepBedTime(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult {
|
||||
if (activeSleepDiaryEntry() != null) return SleepQuickActionResult.AlreadyActive
|
||||
val dates = quickSleepDates(at) ?: return SleepQuickActionResult.Error
|
||||
val result = saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = dates.first,
|
||||
nightEndDate = dates.second,
|
||||
createdAt = at,
|
||||
updatedAt = at,
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = listOf(SleepDiaryEvent(sleepId("sle"), SleepEventType.BED_TIME, at, null)),
|
||||
),
|
||||
)
|
||||
return when (result) {
|
||||
is SaveSleepDiaryResult.Saved ->
|
||||
SleepQuickActionResult.Applied(
|
||||
SleepQuickActionReceipt(result.entryId, null, result.revisionId),
|
||||
)
|
||||
SaveSleepDiaryResult.Conflict -> SleepQuickActionResult.Conflict
|
||||
else -> SleepQuickActionResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
private fun mutateActiveSleep(
|
||||
at: String,
|
||||
transform: (SleepDiaryEntry) -> SleepDiaryDraft,
|
||||
): SleepQuickActionResult {
|
||||
if (runCatching { OffsetDateTime.parse(at) }.isFailure) return SleepQuickActionResult.Error
|
||||
val current = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
|
||||
return when (val result = saveSleepDiary(transform(current))) {
|
||||
is SaveSleepDiaryResult.Saved ->
|
||||
SleepQuickActionResult.Applied(
|
||||
SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId),
|
||||
)
|
||||
SaveSleepDiaryResult.Conflict -> SleepQuickActionResult.Conflict
|
||||
else -> SleepQuickActionResult.Error
|
||||
}
|
||||
}
|
||||
|
||||
private fun SleepDiaryEntry.quickDraft(
|
||||
at: String,
|
||||
events: List<SleepDiaryEvent> = this.events,
|
||||
intakes: List<MedicationIntake> = this.intakes,
|
||||
) = SleepDiaryDraft(
|
||||
entryId = entryId,
|
||||
expectedRevision = revisionId,
|
||||
nightStartDate = nightStartDate,
|
||||
nightEndDate = nightEndDate,
|
||||
createdAt = createdAt,
|
||||
updatedAt = at,
|
||||
sleepQuality = sleepQuality,
|
||||
wakeQuality = wakeQuality,
|
||||
dayForm = dayForm,
|
||||
treatmentAndNotes = treatmentAndNotes,
|
||||
events = events,
|
||||
intakes = intakes,
|
||||
)
|
||||
|
||||
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
|
||||
mutateActiveSleep(at) { current ->
|
||||
current.quickDraft(
|
||||
at,
|
||||
events = current.events + SleepDiaryEvent(
|
||||
sleepId("sle"),
|
||||
SleepEventType.NIGHT_GET_UP,
|
||||
at,
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun quickSleepMedication(
|
||||
medicationId: String,
|
||||
at: String = OffsetDateTime.now().toString(),
|
||||
): SleepQuickActionResult {
|
||||
val medication =
|
||||
listSleepMedications(includeInactive = false)
|
||||
.firstOrNull { it.medicationId == medicationId }
|
||||
?: return SleepQuickActionResult.InvalidMedication
|
||||
return mutateActiveSleep(at) { current ->
|
||||
current.quickDraft(
|
||||
at,
|
||||
intakes = current.intakes + MedicationIntake(
|
||||
intakeId = sleepId("mdi"),
|
||||
medicationId = medication.medicationId,
|
||||
medicationName = medication.name,
|
||||
takenAt = at,
|
||||
doseValue = medication.defaultDoseValue,
|
||||
doseUnit = medication.defaultDoseUnit,
|
||||
note = "",
|
||||
createdAt = at,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun quickSleepFinalGetUp(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult {
|
||||
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
|
||||
val result =
|
||||
mutateActiveSleep(at) { current ->
|
||||
current.quickDraft(
|
||||
at,
|
||||
events = current.events + SleepDiaryEvent(
|
||||
sleepId("sle"),
|
||||
SleepEventType.FINAL_GET_UP,
|
||||
at,
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (result is SleepQuickActionResult.Applied) {
|
||||
activeHeartRateCapture()
|
||||
?.takeIf {
|
||||
it.contextKind == HeartRateContextKind.SLEEP &&
|
||||
it.contextId == activeBefore.entryId
|
||||
}
|
||||
?.let { stopHeartRateCapture(it.captureId, at) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadSleepDiaryRevision(entryId: String, revisionId: String): SleepDiaryEntry? {
|
||||
val db = database.readableDatabase
|
||||
val owner = db.rawQuery(
|
||||
"SELECT night_start_date,night_end_date,created_at,updated_at FROM sleep_diary_entries " +
|
||||
"WHERE entry_id=? AND deleted=0",
|
||||
arrayOf(entryId),
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) null else arrayOf(c.getString(0), c.getString(1), c.getString(2), c.getString(3))
|
||||
} ?: return null
|
||||
val values = db.rawQuery(
|
||||
"SELECT sleep_quality,wake_quality,day_form,treatment_and_notes FROM sleep_diary_revisions " +
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
arrayOf(entryId, revisionId),
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) null else arrayOf(c.getString(0), c.getString(1), c.getString(2), c.getString(3))
|
||||
} ?: return null
|
||||
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(revisionId),
|
||||
).use { c -> buildList {
|
||||
while (c.moveToNext()) add(SleepDiaryEvent(c.getString(0), SleepEventType.entries.single { it.wireValue == c.getString(1) }, c.getString(2), if (c.isNull(3)) null else c.getString(3)))
|
||||
} }
|
||||
val intakes = db.rawQuery(
|
||||
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at " +
|
||||
"FROM sleep_medication_intakes WHERE revision_id=? ORDER BY taken_at,intake_id",
|
||||
arrayOf(revisionId),
|
||||
).use { c -> buildList {
|
||||
while (c.moveToNext()) add(MedicationIntake(c.getString(0), c.getString(1), c.getString(2), c.getString(3), if (c.isNull(4)) null else c.getDouble(4), if (c.isNull(5)) null else c.getString(5), c.getString(6), c.getString(7)))
|
||||
} }
|
||||
fun quality(v: String?) = v?.let { text -> SleepQuality.entries.single { it.wireValue == text } }
|
||||
return SleepDiaryEntry(entryId, owner[0], owner[1], owner[2], owner[3], revisionId,
|
||||
quality(values[0]), quality(values[1]), quality(values[2]), values[3].orEmpty(),
|
||||
events, intakes, SleepPublicationStatus.DRAFT)
|
||||
}
|
||||
|
||||
fun undoSleepQuickAction(
|
||||
receipt: SleepQuickActionReceipt,
|
||||
at: String = OffsetDateTime.now().toString(),
|
||||
): SleepQuickActionResult {
|
||||
val current = listSleepDiary().firstOrNull { it.entryId == receipt.entryId }
|
||||
?: return SleepQuickActionResult.Conflict
|
||||
if (current.revisionId != receipt.appliedRevisionId) return SleepQuickActionResult.Conflict
|
||||
if (receipt.previousRevisionId == null) {
|
||||
return when (deleteSleepDiary(current.entryId, current.revisionId, at)) {
|
||||
is SaveSleepDiaryResult.Saved ->
|
||||
SleepQuickActionResult.Applied(
|
||||
SleepQuickActionReceipt(current.entryId, current.revisionId, current.revisionId),
|
||||
)
|
||||
else -> SleepQuickActionResult.Conflict
|
||||
}
|
||||
}
|
||||
val previous = loadSleepDiaryRevision(current.entryId, receipt.previousRevisionId)
|
||||
?: return SleepQuickActionResult.Conflict
|
||||
val compensating =
|
||||
SleepDiaryDraft(
|
||||
entryId = current.entryId,
|
||||
expectedRevision = current.revisionId,
|
||||
nightStartDate = previous.nightStartDate,
|
||||
nightEndDate = previous.nightEndDate,
|
||||
createdAt = previous.createdAt,
|
||||
updatedAt = at,
|
||||
sleepQuality = previous.sleepQuality,
|
||||
wakeQuality = previous.wakeQuality,
|
||||
dayForm = previous.dayForm,
|
||||
treatmentAndNotes = previous.treatmentAndNotes,
|
||||
events = previous.events,
|
||||
intakes = previous.intakes,
|
||||
)
|
||||
return when (val result = saveSleepDiary(compensating)) {
|
||||
is SaveSleepDiaryResult.Saved ->
|
||||
SleepQuickActionResult.Applied(
|
||||
SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId),
|
||||
)
|
||||
else -> SleepQuickActionResult.Conflict
|
||||
}
|
||||
}
|
||||
|
||||
internal fun recordLiveHeartRateForActiveSleep(
|
||||
sensorName: String?,
|
||||
observedAt: String,
|
||||
measurement: ParsedHeartRateMeasurement,
|
||||
): HeartRateMutationResult? {
|
||||
if (activeSessionTimelineContext() != null) return null
|
||||
val db = database.readableDatabase
|
||||
val sleep = db.rawQuery(
|
||||
"SELECT e.entry_id,bed.start_at FROM sleep_diary_entries e " +
|
||||
"JOIN sleep_diary_events bed ON bed.revision_id=e.current_revision_id " +
|
||||
"AND bed.event_type='bed_time' WHERE e.deleted=0 AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sleep_diary_events wake WHERE wake.revision_id=e.current_revision_id " +
|
||||
"AND wake.event_type='final_get_up') ORDER BY bed.start_at DESC LIMIT 1",
|
||||
null,
|
||||
).use { c -> if (!c.moveToFirst()) null else c.getString(0) to c.getString(1) } ?: return null
|
||||
|
||||
var capture = activeHeartRateCapture()
|
||||
if (capture == null) {
|
||||
when (startHeartRateCapture(HeartRateContextKind.SLEEP, sleep.first, sleep.second, sensorName)) {
|
||||
is StartHeartRateCaptureResult.Started -> capture = activeHeartRateCapture()
|
||||
StartHeartRateCaptureResult.Conflict -> capture = activeHeartRateCapture()
|
||||
else -> return HeartRateMutationResult.DatabaseError
|
||||
}
|
||||
}
|
||||
val current = capture ?: return HeartRateMutationResult.NotFound
|
||||
if (current.contextKind != HeartRateContextKind.SLEEP || current.contextId != sleep.first) return null
|
||||
return appendHeartRateSample(
|
||||
current.captureId,
|
||||
observedAt,
|
||||
measurement.bpm,
|
||||
measurement.sensorContactDetected,
|
||||
measurement.energyExpended,
|
||||
measurement.rrIntervals1024.mapIndexed { index, value -> HeartRateRrInterval(index, value) },
|
||||
)
|
||||
}
|
||||
|
||||
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
|
||||
require(limit in 1..3660)
|
||||
val db = database.readableDatabase
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -37,8 +38,240 @@ import java.time.LocalDate
|
|||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@Composable
|
||||
fun SleepDiaryScreen(repository: TrainlogRepository) {
|
||||
var correctionMode by remember { mutableStateOf(false) }
|
||||
if (correctionMode) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Button(
|
||||
onClick = { correctionMode = false },
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_return_night_mode))
|
||||
}
|
||||
SleepDiaryEditorScreen(repository)
|
||||
}
|
||||
} else {
|
||||
SleepNightCaptureScreen(repository) { correctionMode = true }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SleepNightCaptureScreen(
|
||||
repository: TrainlogRepository,
|
||||
onCorrection: () -> Unit,
|
||||
) {
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
var undoReceipt by remember {
|
||||
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
|
||||
}
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val strings = localizedContext()
|
||||
val entries = remember(revision) { repository.listSleepDiary() }
|
||||
val active =
|
||||
entries.firstOrNull { entry ->
|
||||
entry.events.any { it.type == SleepEventType.BED_TIME } &&
|
||||
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
||||
}
|
||||
val medications =
|
||||
remember(revision) {
|
||||
repository.listSleepMedications(includeInactive = false)
|
||||
}
|
||||
|
||||
LaunchedEffect(undoReceipt?.appliedRevisionId) {
|
||||
if (undoReceipt != null) {
|
||||
delay(10_000)
|
||||
undoReceipt = null
|
||||
}
|
||||
}
|
||||
|
||||
fun apply(
|
||||
successMessage: String,
|
||||
result: TrainlogRepository.SleepQuickActionResult,
|
||||
) {
|
||||
when (result) {
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
||||
undoReceipt = result.receipt
|
||||
message = successMessage
|
||||
revision++
|
||||
}
|
||||
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
|
||||
message = strings.getString(R.string.sleep_quick_no_active)
|
||||
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
|
||||
message = strings.getString(R.string.sleep_quick_already_active)
|
||||
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
|
||||
message = strings.getString(R.string.sleep_quick_medication_unavailable)
|
||||
TrainlogRepository.SleepQuickActionResult.Conflict ->
|
||||
message = strings.getString(R.string.sleep_quick_conflict)
|
||||
TrainlogRepository.SleepQuickActionResult.Error ->
|
||||
message = strings.getString(R.string.sleep_quick_error)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(stringResource(R.string.sleep_night_capture_title))
|
||||
}
|
||||
item {
|
||||
active?.let { night ->
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.sleep_night_in_progress,
|
||||
night.nightStartDate,
|
||||
night.nightEndDate,
|
||||
),
|
||||
)
|
||||
} ?: Text(stringResource(R.string.sleep_no_night_active))
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
apply(
|
||||
strings.getString(R.string.sleep_bed_recorded),
|
||||
repository.quickSleepBedTime(),
|
||||
)
|
||||
},
|
||||
enabled = active == null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_quick_bed))
|
||||
}
|
||||
}
|
||||
if (medications.isNotEmpty()) {
|
||||
item { Text(stringResource(R.string.sleep_medications)) }
|
||||
items(medications, key = { it.medicationId }) { medication ->
|
||||
Button(
|
||||
onClick = {
|
||||
apply(
|
||||
strings.getString(R.string.sleep_medication_recorded, medication.name),
|
||||
repository.quickSleepMedication(medication.medicationId),
|
||||
)
|
||||
},
|
||||
enabled = active != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val dose =
|
||||
medication.defaultDoseValue?.let { doseValue ->
|
||||
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
|
||||
" " + medication.defaultDoseUnit.orEmpty()
|
||||
}.orEmpty()
|
||||
Text("💊 " + medication.name + dose)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
apply(
|
||||
strings.getString(R.string.sleep_wake_recorded),
|
||||
repository.quickSleepWake(),
|
||||
)
|
||||
},
|
||||
enabled = active != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_quick_wake))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
apply(
|
||||
strings.getString(R.string.sleep_get_up_recorded),
|
||||
repository.quickSleepFinalGetUp(),
|
||||
)
|
||||
},
|
||||
enabled = active != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_quick_get_up))
|
||||
}
|
||||
}
|
||||
message?.let { text ->
|
||||
item {
|
||||
Card {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text)
|
||||
undoReceipt?.let { receipt ->
|
||||
Button(
|
||||
onClick = {
|
||||
when (repository.undoSleepQuickAction(receipt)) {
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
||||
message = strings.getString(R.string.sleep_action_undone)
|
||||
undoReceipt = null
|
||||
revision++
|
||||
}
|
||||
else -> {
|
||||
message = strings.getString(R.string.sleep_undo_unavailable)
|
||||
undoReceipt = null
|
||||
revision++
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_undo))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
active?.let { night ->
|
||||
item {
|
||||
Card {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_night_timeline))
|
||||
night.events.sortedBy { it.startAt }.forEach { event ->
|
||||
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
|
||||
}
|
||||
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
|
||||
Text("💊 " + intake.medicationName + " · " + shortTime(intake.takenAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = onCorrection,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.sleep_correct_night))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortTime(value: String): String =
|
||||
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
|
||||
.getOrDefault(value)
|
||||
|
||||
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
|
||||
context.getString(
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
||||
val today = LocalDate.now()
|
||||
|
|
|
|||
|
|
@ -536,4 +536,27 @@
|
|||
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
|
||||
|
||||
<string name="sleep_night_capture_title">Night capture</string>
|
||||
<string name="sleep_no_night_active">No night in progress.</string>
|
||||
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
|
||||
<string name="sleep_quick_bed">🛏 BED</string>
|
||||
<string name="sleep_quick_wake">👁 WAKE</string>
|
||||
<string name="sleep_quick_get_up">☀ GET UP</string>
|
||||
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
||||
<string name="sleep_wake_recorded">Wake recorded.</string>
|
||||
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
||||
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
||||
<string name="sleep_undo">UNDO</string>
|
||||
<string name="sleep_action_undone">Last action undone.</string>
|
||||
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
|
||||
<string name="sleep_correct_night">Correct the night</string>
|
||||
<string name="sleep_return_night_mode">Back to night capture</string>
|
||||
<string name="sleep_night_timeline">Night timeline</string>
|
||||
|
||||
<string name="sleep_quick_no_active">No night in progress.</string>
|
||||
<string name="sleep_quick_already_active">A night is already in progress.</string>
|
||||
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
|
||||
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
|
||||
<string name="sleep_quick_error">Unable to record the action.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -546,4 +546,27 @@
|
|||
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
|
||||
|
||||
<string name="sleep_night_capture_title">Saisie nocturne</string>
|
||||
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
|
||||
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
|
||||
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
|
||||
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
||||
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
||||
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
||||
<string name="sleep_wake_recorded">Réveil enregistré.</string>
|
||||
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
||||
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
||||
<string name="sleep_undo">ANNULER</string>
|
||||
<string name="sleep_action_undone">Dernière action annulée.</string>
|
||||
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
|
||||
<string name="sleep_correct_night">Corriger la nuit</string>
|
||||
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
|
||||
<string name="sleep_night_timeline">Chronologie de la nuit</string>
|
||||
|
||||
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
|
||||
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
|
||||
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
|
||||
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
|
||||
<string name="sleep_quick_error">Enregistrement impossible.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.SleepEventType
|
||||
import com.labfytools.trainlog.model.SleepMedication
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
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 SleepQuickCaptureTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
|
||||
val name = "sleep-quick-" + UUID.randomUUID()
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val medicationCreated =
|
||||
repository.saveSleepMedication(
|
||||
SleepMedication(
|
||||
medicationId = "",
|
||||
revisionId = "",
|
||||
createdAt = "2026-09-23T20:00:00+02:00",
|
||||
updatedAt = "2026-09-23T20:00:00+02:00",
|
||||
name = "Test medication",
|
||||
defaultDoseValue = 5.0,
|
||||
defaultDoseUnit = "mg",
|
||||
form = "",
|
||||
note = "",
|
||||
active = true,
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val medication =
|
||||
repository.listSleepMedications(false).single {
|
||||
it.medicationId == medicationCreated.entryId
|
||||
}
|
||||
|
||||
val bed =
|
||||
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
|
||||
|
||||
val measurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 62,
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(1000),
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSleep(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T22:30:01+02:00",
|
||||
measurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||
|
||||
assertTrue(
|
||||
repository.quickSleepMedication(
|
||||
medication.medicationId,
|
||||
"2026-09-23T22:35:00+02:00",
|
||||
) is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||
)
|
||||
|
||||
val beforeGetUp = repository.listSleepDiary().single()
|
||||
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
||||
assertEquals(
|
||||
2,
|
||||
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
|
||||
)
|
||||
assertEquals(1, beforeGetUp.intakes.size)
|
||||
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.single().doseValue), 0.0)
|
||||
|
||||
assertTrue(
|
||||
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||
)
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
val finalEntry = repository.listSleepDiary().single()
|
||||
assertEquals(
|
||||
1,
|
||||
finalEntry.events.count { it.type == SleepEventType.FINAL_GET_UP },
|
||||
)
|
||||
|
||||
val exported = JSONObject(repository.buildHeartRateV1Json())
|
||||
val capture = exported.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(captureId, capture.getString("capture_id"))
|
||||
assertEquals("sleep", capture.getString("context_kind"))
|
||||
assertEquals(finalEntry.entryId, capture.getString("context_id"))
|
||||
assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoIsGuardedByTheExactAppliedRevision() {
|
||||
val name = "sleep-undo-" + UUID.randomUUID()
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||
val firstWake =
|
||||
repository.quickSleepWake("2026-09-24T02:00:00+02:00")
|
||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
val secondWake =
|
||||
repository.quickSleepWake("2026-09-24T03:00:00+02:00")
|
||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
|
||||
assertTrue(
|
||||
repository.undoSleepQuickAction(firstWake.receipt)
|
||||
is TrainlogRepository.SleepQuickActionResult.Conflict,
|
||||
)
|
||||
assertTrue(
|
||||
repository.undoSleepQuickAction(
|
||||
secondWake.receipt,
|
||||
"2026-09-24T03:00:05+02:00",
|
||||
) is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||
)
|
||||
|
||||
val night = repository.listSleepDiary().single()
|
||||
assertEquals(
|
||||
listOf("2026-09-24T02:00:00+02:00"),
|
||||
night.events
|
||||
.filter { it.type == SleepEventType.NIGHT_GET_UP }
|
||||
.map { it.startAt },
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +151,8 @@ parallel implementation of its rules.
|
|||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
||||
| Current operational cursor | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1`: add the Android Sleep entry point with one-tap Couché / medication / Réveil / Levé, immediate undo, later correction, and sleep-linked cardio capture |
|
||||
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_SESSION_TYPE_V1`: add the true `cardio` session type without silently widening frozen legacy formats |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
|||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_SESSION_TYPE_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1744,3 +1744,29 @@ The 2026-09-23 closeout passes the complete native Meson inventory:
|
|||
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
|
||||
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||
also succeeds. The five skips remain optional historical/external fixture gates.
|
||||
|
||||
## Android one-tap Sleep V1 validation
|
||||
|
||||
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
|
||||
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
|
||||
API creates/advances normal immutable Sleep Diary revisions for Couché,
|
||||
medication intake, point Réveil and Levé actions. Undo is accepted only when
|
||||
the action's exact applied revision is still current; older receipts conflict,
|
||||
and successful undo appends a compensating revision instead of rewriting the
|
||||
history.
|
||||
|
||||
Targeted coverage executes Couché, a configured medication with dose snapshot,
|
||||
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
|
||||
heart-rate export against the same stable sleep `entry_id`. A separate test
|
||||
proves stale undo rejection and exact restoration of the latest action.
|
||||
|
||||
The complete Android JVM inventory before the final localization-only cleanup
|
||||
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
|
||||
`assembleDebug` succeeded. After the localization cleanup the targeted
|
||||
SleepQuickCapture test and `assembleDebug` passed again.
|
||||
|
||||
The private Android installation was advanced non-destructively to Trainlog
|
||||
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
|
||||
data clear. The device locked before the final visual Sleep-screen inspection,
|
||||
so no lock bypass was attempted and no real Sleep business button was pressed;
|
||||
that visual review remains explicitly pending.
|
||||
|
|
|
|||
Loading…
Reference in a new issue