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`,
|
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
|
||||||
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
||||||
V1 companion imports idempotently into desktop schema v31.
|
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.
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,9 +277,15 @@ class HeartRateSensorService : Service() {
|
||||||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||||
HeartRateLiveState.measurement(parsed.measurement)
|
HeartRateLiveState.measurement(parsed.measurement)
|
||||||
repository().recordLiveHeartRateForActiveSession(
|
val observedAt = OffsetDateTime.now().toString()
|
||||||
|
val repository = repository()
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
sensorName = selected?.name,
|
sensorName = selected?.name,
|
||||||
observedAt = OffsetDateTime.now().toString(),
|
observedAt = observedAt,
|
||||||
|
measurement = parsed.measurement,
|
||||||
|
) ?: repository.recordLiveHeartRateForActiveSleep(
|
||||||
|
sensorName = selected?.name,
|
||||||
|
observedAt = observedAt,
|
||||||
measurement = parsed.measurement,
|
measurement = parsed.measurement,
|
||||||
)
|
)
|
||||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,21 @@ class TrainlogRepository(
|
||||||
data class Rejected(val reason: String) : SleepDiaryImportResult
|
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 {
|
sealed interface StartHeartRateCaptureResult {
|
||||||
data class Started(val captureId: String) : StartHeartRateCaptureResult
|
data class Started(val captureId: String) : StartHeartRateCaptureResult
|
||||||
data object Invalid : StartHeartRateCaptureResult
|
data object Invalid : StartHeartRateCaptureResult
|
||||||
|
|
@ -668,6 +683,257 @@ class TrainlogRepository(
|
||||||
} finally { db.endTransaction() }
|
} 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> {
|
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
|
||||||
require(limit in 1..3660)
|
require(limit in 1..3660)
|
||||||
val db = database.readableDatabase
|
val db = database.readableDatabase
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -37,8 +38,240 @@ import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SleepDiaryScreen(repository: TrainlogRepository) {
|
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 revision by remember { mutableStateOf(0) }
|
||||||
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
||||||
val today = LocalDate.now()
|
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_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_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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -546,4 +546,27 @@
|
||||||
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
|
<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_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="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>
|
</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 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 |
|
| 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 |
|
| 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 |
|
| 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` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_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.
|
Bluetooth synchronization transport contract.
|
||||||
|
|
||||||
```text
|
```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
|
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
|
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
|
||||||
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
|
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||||
also succeeds. The five skips remain optional historical/external fixture gates.
|
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