From 8f572f0c688df76d3665f508040a00fbb39143eb Mon Sep 17 00:00:00 2001 From: prasad Date: Sun, 27 Sep 2026 22:50:16 -0400 Subject: [PATCH] fix(sleep): announce meaningful displayed result changes --- Strand/Resources/Localizable.xcstrings | 3 + Strand/Screens/SleepResultChange.swift | 31 +++++++++ Strand/Screens/SleepView.swift | 63 ++++++++++++++++++- StrandTests/SleepResultChangeTests.swift | 34 ++++++++++ .../java/com/noop/ui/SleepResultChange.kt | 33 ++++++++++ .../src/main/java/com/noop/ui/SleepScreen.kt | 50 +++++++++++++-- .../app/src/main/res/values-de/strings.xml | 2 + .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 2 + .../app/src/main/res/values-it/strings.xml | 2 + .../app/src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt-rPT/strings.xml | 2 + .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values-zh/strings.xml | 2 + android/app/src/main/res/values/strings.xml | 2 + .../java/com/noop/ui/SleepResultChangeTest.kt | 36 +++++++++++ 16 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 Strand/Screens/SleepResultChange.swift create mode 100644 StrandTests/SleepResultChangeTests.swift create mode 100644 android/app/src/main/java/com/noop/ui/SleepResultChange.kt create mode 100644 android/app/src/test/java/com/noop/ui/SleepResultChangeTest.kt diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 8666ccb995..3f12966fec 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage": "en", "strings": { +"Sleep result updated": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Sleep result updated"}}, "de": {"stringUnit": {"state": "translated", "value": "Schlafergebnis aktualisiert"}}, "es": {"stringUnit": {"state": "translated", "value": "Resultado del sueño actualizado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Résultat du sommeil mis à jour"}}, "it": {"stringUnit": {"state": "translated", "value": "Risultato del sonno aggiornato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaktualizowano wynik snu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Resultado do sono atualizado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Результат сна обновлён"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡眠结果已更新"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡眠結果已更新"}}}}, +"This night's sleep times or total asleep changed by at least five minutes.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "This night's sleep times or total asleep changed by at least five minutes."}}, "de": {"stringUnit": {"state": "translated", "value": "Die Schlafzeiten oder die Gesamtschlafdauer dieser Nacht haben sich um mindestens fünf Minuten geändert."}}, "es": {"stringUnit": {"state": "translated", "value": "Las horas de sueño o el tiempo total dormido de esta noche cambiaron al menos cinco minutos."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les horaires de sommeil ou la durée totale de sommeil de cette nuit ont changé d’au moins cinq minutes."}}, "it": {"stringUnit": {"state": "translated", "value": "Gli orari del sonno o il tempo totale dormito di questa notte sono cambiati di almeno cinque minuti."}}, "pl": {"stringUnit": {"state": "translated", "value": "Godziny snu lub łączny czas snu tej nocy zmieniły się o co najmniej pięć minut."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os horários de sono ou o tempo total de sono desta noite mudaram pelo menos cinco minutos."}}, "ru": {"stringUnit": {"state": "translated", "value": "Время засыпания, пробуждения или общая продолжительность сна этой ночи изменились как минимум на пять минут."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这晚的入睡时间、醒来时间或总睡眠时长变化了至少五分钟。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這晚的入睡時間、醒來時間或總睡眠時長變化了至少五分鐘。"}}}}, + "Experimental · Oura ring all-day heart rate": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Experimentell · Oura-Ring: Herzfrequenz den ganzen Tag"}}, "es": {"stringUnit": {"state": "translated", "value": "Experimental · Anillo Oura: frecuencia cardiaca todo el día"}}, diff --git a/Strand/Screens/SleepResultChange.swift b/Strand/Screens/SleepResultChange.swift new file mode 100644 index 0000000000..183f3e59ef --- /dev/null +++ b/Strand/Screens/SleepResultChange.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Presentation-only comparison of a fully loaded, displayed night. Five minutes is a +/// notification threshold, not an accuracy claim. Stage redistribution alone stays quiet. +/// Keep the baseline across smaller changes so cumulative drift can cross the threshold. +struct SleepResultSnapshot: Equatable { + let scope: String + let onset: Int + let wake: Int + let asleepMinutes: Double + let edited: Bool +} + +struct SleepResultChangeTracker { + private var baseline: SleepResultSnapshot? + + mutating func observe(_ next: SleepResultSnapshot?, ready: Bool) -> Bool { + guard ready else { return false } + guard let next else { baseline = nil; return false } + guard let previous = baseline, previous.scope == next.scope, + !previous.edited, !next.edited else { + baseline = next + return false + } + let changed = abs(next.onset - previous.onset) >= 300 + || abs(next.wake - previous.wake) >= 300 + || abs(next.asleepMinutes - previous.asleepMinutes) >= 5 + if changed { baseline = next } + return changed + } +} diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 8cfdaf8d7b..67baae85d1 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -50,6 +50,11 @@ struct SleepView: View { /// The repo signature the cached `model` was built from. Cheap to compute every render; /// when it differs from the current inputs we rebuild the model. @State private var modelKey: SleepInputKey? + @State private var loadedSleepRefresh: Int? + @State private var resultTracker = SleepResultChangeTracker() + @State private var resultNoticeVisible = false + @State private var resultNoticeRevision = 0 + @State private var resultNoticeScope: String? /// Which night the hero hypnogram shows: 0 = last night, N = N sleep-sessions back. /// Snaps back to 0 whenever the data key changes — a stale offset would silently point @@ -165,6 +170,11 @@ struct SleepView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { if let sleepUndo { sleepUndoBanner(sleepUndo) } SleepFreshnessNote(latestWakeTs: resolved.night.session.endTs) + if resultNoticeVisible { + DataPendingNote(title: "Sleep result updated", + message: "This night's sleep times or total asleep changed by at least five minutes.", + symbol: "checkmark.circle") + } // Bleed past ScreenScaffold's 16/24 gutters so the hero column is edge-to-edge // in the upper band; the night scene itself is the fixed topBackground. // Customize sits at the end of the hero (not floating in a blank band). @@ -201,6 +211,8 @@ struct SleepView: View { // `decodedNight` JSON-decodes and body re-evaluates at 1Hz while HR streams. (#160) .onChangeCompat(of: nightOffset) { newOffset in navNight = newOffset == 0 ? nil : decodedNight(at: newOffset) + resetResultNotice() + observeResultChange() } .onAppear { if modelKey != key { @@ -210,24 +222,39 @@ struct SleepView: View { navNight = nil } } + .onChangeCompat(of: intelligence.computing) { _ in observeResultChange() } + .onDisappear { resetResultNotice() } + .task(id: resultNoticeRevision) { + guard resultNoticeVisible else { return } + do { try await Task.sleep(nanoseconds: 8_000_000_000) } + catch { return } + resultNoticeVisible = false + } // Load EVERY sleep block across BOTH sources (un-deduplicated) so the hero's ◀/▶ can // browse split-sleep days the dashboard collapses — including Bluetooth-only nights, // whose blocks live under the computed source. Re-runs whenever a sync/import bumps // refreshSeq; snaps back to the newest day and rebuilds the model so offset 0 reflects // the freshly-loaded blocks. (#170) .task(id: repo.refreshSeq) { - allSessions = await repo.allSleepSessions() + let refresh = repo.refreshSeq + let sessions = await repo.allSleepSessions() // Load the learned habitual midsleep the engine used, so the main-night pick aligns to it // (a shift/late sleeper) instead of only the cold-start band. nil under threshold. (#547) - habitualMidsleepSec = await repo.habitualMidsleepSec() + let habitual = await repo.habitualMidsleepSec() // Per-epoch motion for every block (#407), keyed by detected start. mergeDay reads only the // already-resolved group's entries — this just pre-fetches them all so the model build is sync. - motionByStart = await repo.sessionMotions(sessions: allSessions) + let motions = await repo.sessionMotions(sessions: sessions) + guard !Task.isCancelled, refresh == repo.refreshSeq else { return } + allSessions = sessions + habitualMidsleepSec = habitual + motionByStart = motions nightOffset = 0 navNight = nil modelKey = dataKey navDaysCache = SleepModel.navDays(navSessions: navSessions) model = buildModel() + loadedSleepRefresh = refresh + observeResultChange() } .sheet(item: $wakeEdit) { edit in // The night's RECORDED coverage for the #940 guards: from the immutable detected @@ -1806,6 +1833,36 @@ struct SleepView: View { typicalTotalMin: model.typicalTotalMin)) } + /// Compare only after the screen has loaded the refreshed blocks and scoring has settled. + /// The observer lives on the screen, so a hidden notice cannot stop change observation. + private func observeResultChange() { + guard loadedSleepRefresh == repo.refreshSeq, !intelligence.computing else { return } + guard let model else { resetResultNotice(); return } + let snapshot = resultSnapshot(heroNight(model)) + if resultNoticeScope != snapshot.scope { resultNoticeVisible = false } + resultNoticeScope = snapshot.scope + if resultTracker.observe(snapshot, ready: true) { + resultNoticeVisible = true + resultNoticeRevision += 1 + } + } + + private func resetResultNotice() { + resultTracker = SleepResultChangeTracker() + resultNoticeVisible = false + resultNoticeScope = nil + } + + private func resultSnapshot(_ night: Night) -> SleepResultSnapshot { + let day = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: Double(night.session.endTs))) + let sources = Set(night.sourceBlocks.compactMap(\.deviceId)).sorted().joined(separator: ",") + return SleepResultSnapshot( + scope: "\(repo.deviceId):\(sources):\(day.timeIntervalSince1970)", + onset: night.session.effectiveStartTs, wake: night.session.endTs, + asleepMinutes: night.stages.asleep, + edited: night.session.userEdited || night.sourceBlocks.contains { $0.userEdited }) + } + // MARK: - Memoization plumbing /// A cheap fingerprint of the repo inputs this screen derives from. Recomputed every diff --git a/StrandTests/SleepResultChangeTests.swift b/StrandTests/SleepResultChangeTests.swift new file mode 100644 index 0000000000..259952477a --- /dev/null +++ b/StrandTests/SleepResultChangeTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import Strand + +final class SleepResultChangeTests: XCTestCase { + func testResultChangeOracle() { + var tracker = SleepResultChangeTracker() + func sample(_ scope: String = "strap:source:night", _ onset: Int = 1000, + _ wake: Int = 30000, _ asleep: Double = 420, _ edited: Bool = false) -> SleepResultSnapshot { + SleepResultSnapshot(scope: scope, onset: onset, wake: wake, asleepMinutes: asleep, edited: edited) + } + var actual: [Bool] = [] + func observe(_ snapshot: SleepResultSnapshot?, _ ready: Bool = true) { + actual.append(tracker.observe(snapshot, ready: ready)) + } + observe(sample()) // First display. + observe(sample()) // Unrelated pass, same displayed result. + observe(sample("strap:source:night", 1060, 30060, 421)) // One-minute shift. + observe(sample("strap:source:night", 1299, 30299, 424.99)) // Below threshold. + observe(sample("strap:source:night", 1300)) // Cumulative onset threshold. + observe(sample("strap:source:night", 1300)) // Same result does not re-announce. + observe(sample("strap:source:night", 1300, 30300), false) // In-progress write. + observe(sample("strap:source:night", 1300, 30300)) // Completed wake change. + observe(sample("strap:source:night", 1300, 30300, 425)) // Total asleep. + observe(sample("strap:source:night", 1300, 30300, 420)) // Decrease also matters. + observe(sample("other-night")) // Navigation/new night. + observe(sample("other-strap")) // Device/source change. + observe(sample("other-strap", 2000, 31000, 430, true)) // Manual edit. + observe(sample("other-strap", 3000, 32000, 440, true)) + observe(nil) // No visible night resets baseline. + observe(sample()) + XCTAssertEqual(actual, [false, false, false, false, true, false, false, true, + true, true, false, false, false, false, false, false]) + } +} diff --git a/android/app/src/main/java/com/noop/ui/SleepResultChange.kt b/android/app/src/main/java/com/noop/ui/SleepResultChange.kt new file mode 100644 index 0000000000..9539eb7050 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/SleepResultChange.kt @@ -0,0 +1,33 @@ +package com.noop.ui + +import kotlin.math.abs + +/** Presentation-only twin of Swift SleepResultChangeTracker. Five minutes is a notification + * threshold, not an accuracy claim. Stage redistribution alone stays quiet; small changes + * accumulate against the last announced baseline. */ +internal data class SleepResultSnapshot( + val scope: String, + val onset: Long, + val wake: Long, + val asleepMinutes: Double, + val edited: Boolean, +) + +internal class SleepResultChangeTracker { + private var baseline: SleepResultSnapshot? = null + + fun observe(next: SleepResultSnapshot?, ready: Boolean): Boolean { + if (!ready) return false + if (next == null) { baseline = null; return false } + val previous = baseline + if (previous == null || previous.scope != next.scope || previous.edited || next.edited) { + baseline = next + return false + } + val changed = abs(next.onset - previous.onset) >= 300L || + abs(next.wake - previous.wake) >= 300L || + abs(next.asleepMinutes - previous.asleepMinutes) >= 5.0 + if (changed) baseline = next + return changed + } +} diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 65a19060ab..6fc9098e78 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -45,11 +45,13 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.foundation.lazy.rememberLazyListState +import kotlinx.coroutines.ensureActive import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import com.noop.data.DailyMetric import com.noop.data.HrBucket import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -234,6 +236,9 @@ fun SleepScreen( // mergeSleep but WITHOUT the per-night collapse). Keyed on `days` so a sync/import (which always // rewrites dailyMetric too) reloads; these reads have no Flow. (#160, #170) var sleeps by remember { mutableStateOf>(emptyList()) } + var loadedSleepDays by remember { mutableStateOf?>(null) } + var loadedSleepStrap by remember { mutableStateOf(null) } + val sleepRowsReady = loadedSleepDays == days && loadedSleepStrap == vm.activeStrapId // Durable deleted-night markers. Unlike the 7-second Undo banner these remain reachable after the // session row is gone, giving each suppressed window a "Recompute this night" escape hatch (#515). var dismissedSleeps by remember { mutableStateOf>(emptyList()) } @@ -243,8 +248,10 @@ fun SleepScreen( // `sleeps` in place WITHOUT touching `days`, so it must not reset the browse — keeping the // user on the night they just edited. (#160) var nightOffset by remember { mutableIntStateOf(0) } - LaunchedEffect(days) { - sleeps = runCatching { + LaunchedEffect(days, vm.activeStrapId) { + loadedSleepDays = null + val strap = vm.activeStrapId + val loaded = runCatching { val now = System.currentTimeMillis() / 1000L // Read the ACTIVE-strap ∪ canonical "my-whoop" union (#814/#1008), not the canonical id // alone: after a strap remove+re-add live nights land under the fresh "whoop-" id, so @@ -252,8 +259,8 @@ fun SleepScreen( // union-joined surface moved on (the #1014/#1009 stuck-sleep divergence, in the OTHER // direction). Exact-duplicate (startTs, endTs) blocks recorded under both ids are dropped; // naps/split blocks survive. Single-device installs collapse to one id, byte-identical. - val imported = vm.repo.sleepSessionsUnion(vm.activeStrapId, 0L, now) - val computed = vm.repo.computedSleepSessionsUnion(vm.activeStrapId, 0L, now) + val imported = vm.repo.sleepSessionsUnion(strap, 0L, now) + val computed = vm.repo.computedSleepSessionsUnion(strap, 0L, now) // Key by the LOCAL wake-day (#304), matching WhoopRepository.mergeSleep — a UTC key // mis-attributed a UTC+ user's early-morning wake to yesterday. REUSE the existing // dayString(ts, offsetSec) overload; do not add a new one (it clashes on the JVM). @@ -268,7 +275,11 @@ fun SleepScreen( WhoopRepository.mergeSleepRichness(imported, computed) { localEndDay(it.endTs) } .sortedBy { it.effectiveStartTs } }.getOrDefault(emptyList()) + kotlinx.coroutines.currentCoroutineContext().ensureActive() + sleeps = loaded nightOffset = 0 + loadedSleepDays = days + loadedSleepStrap = strap } // Read the active∪canonical management union so a marker created before a strap re-add remains @@ -531,6 +542,29 @@ fun SleepScreen( } val display = remember(model, night) { heroDisplay(model, night) } + val resultSnapshot = if (night != null && display != null) SleepResultSnapshot( + scope = "${vm.activeStrapId}:${(night.heroGroup.ifEmpty { listOf(night.session) }).map { it.deviceId }.distinct().sorted().joinToString(",")}:${night.dayKey}", + onset = night.heroOnsetTs ?: night.session.effectiveStartTs, + wake = night.heroWakeTs ?: night.session.endTs, + asleepMinutes = display.stages.asleep, + edited = night.session.userEdited || night.heroGroup.any { it.userEdited }, + ) else null + val changeTracker = remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { SleepResultChangeTracker() } + var resultChanged by remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { mutableStateOf(false) } + var resultRevision by remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { mutableIntStateOf(0) } + LaunchedEffect(resultSnapshot, sleepRowsReady, freshnessLive.analyzing, changeTracker) { + if (changeTracker.observe(resultSnapshot, sleepRowsReady && !freshnessLive.analyzing)) { + resultChanged = true + resultRevision++ + } + } + LaunchedEffect(resultRevision, changeTracker) { + if (resultChanged) { + kotlinx.coroutines.delay(8_000) + resultChanged = false + } + } + val sleepFreshness = remember(sleeps, freshnessLive) { val zone = ZoneId.systemDefault() val now = Instant.now().atZone(zone) @@ -651,6 +685,14 @@ fun SleepScreen( ) } } + if (resultChanged) { + item { + DataPendingNote( + title = stringResource(R.string.sleep_result_updated_title), + body = stringResource(R.string.sleep_result_updated_message), + ) + } + } sleepFreshness?.let { status -> item { SleepFreshnessNote(status, backfillNote ?: 0) } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 780b24c4a5..b737503ea1 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2858,4 +2858,6 @@ %1$s, %2$s, in Benutzung Der Alarm wurde mit einem experimentellen 5/MG-Befehl auf dem Strap aktiviert. Weckvorgänge durch den Strap wurden für 5.0 und MG gemeldet, sind aber nicht garantiert. Behalte für alles Wichtige einen Ersatzwecker. Sendet experimentelle Protokollabfragen und aktiviert den 5/MG-Strapalarm. Für normale Synchronisierung, Schlaf, Erholung oder Schritte nicht erforderlich. + Schlafergebnis aktualisiert + Die Schlafzeiten oder die Gesamtschlafdauer dieser Nacht haben sich um mindestens fünf Minuten geändert. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 330fc17d0d..1297c965ce 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2845,4 +2845,6 @@ %1$s, %2$s, en uso La alarma se activó en la pulsera con un comando experimental 5/MG. Se han comunicado despertares desde pulseras 5.0 y MG, pero no están garantizados. Mantén una alarma de respaldo para lo que no puedas perderte. Envía consultas de protocolo experimentales y habilita la alarma de la pulsera 5/MG. No se necesita para la sincronización, el sueño, la recuperación ni los pasos normales. + Resultado del sueño actualizado + Las horas de sueño o el tiempo total dormido de esta noche cambiaron al menos cinco minutos. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 71abc55fcb..78d707720e 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2844,4 +2844,6 @@ %1$s, %2$s, en cours d\'utilisation L’alarme est activée sur le bracelet avec une commande expérimentale 5/MG. Des réveils par le bracelet ont été signalés sur 5.0 et MG, sans être garantis. Gardez une alarme de secours pour tout réveil important. Envoie des requêtes de protocole expérimentales et active l’alarme du bracelet 5/MG. Inutile pour la synchronisation, le sommeil, la récupération ou les pas ordinaires. + Résultat du sommeil mis à jour + Les horaires de sommeil ou la durée totale de sommeil de cette nuit ont changé d’au moins cinq minutes. diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 6f3c66720b..39e2d02eba 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2813,4 +2813,6 @@ %1$s, %2$s, in uso La sveglia è stata attivata sulla fascia con un comando sperimentale 5/MG. Sono stati segnalati risvegli dalla fascia su 5.0 e MG, ma non sono garantiti. Tieni una sveglia di riserva per gli impegni importanti. Invia richieste di protocollo sperimentali e abilita la sveglia della fascia 5/MG. Non serve per la normale sincronizzazione, il sonno, il recupero o i passi. + Risultato del sonno aggiornato + Gli orari del sonno o il tempo totale dormito di questa notte sono cambiati di almeno cinque minuti. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 8a7a2266f7..2251953e07 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2861,4 +2861,6 @@ %1$s, %2$s, w użyciu Alarm uzbrojono na opasce eksperymentalnym poleceniem 5/MG. Zgłaszano wybudzanie przez opaski 5.0 i MG, ale nie jest ono gwarantowane. Zachowaj alarm zapasowy na ważne okazje. Wysyła eksperymentalne zapytania protokołu i włącza alarm opaski 5/MG. Nie jest potrzebne do zwykłej synchronizacji, snu, regeneracji ani kroków. + Zaktualizowano wynik snu + Godziny snu lub łączny czas snu tej nocy zmieniły się o co najmniej pięć minut. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index ebb3d117f2..c32828d519 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2837,4 +2837,6 @@ %1$s, %2$s, em uso O alarme foi armado na pulseira com um comando experimental 5/MG. Foram comunicados despertares pela pulseira em 5.0 e MG, mas não são garantidos. Mantém um alarme de reserva para tudo o que não podes perder. Envia consultas experimentais de protocolo e ativa o alarme da pulseira 5/MG. Não é necessário para a sincronização, o sono, a recuperação ou os passos normais. + Resultado do sono atualizado + Os horários de sono ou o tempo total de sono desta noite mudaram pelo menos cinco minutos. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 15fe66b100..d42c0e2030 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2740,4 +2740,6 @@ %1$s, %2$s, используется Будильник установлен на браслете экспериментальной командой 5/MG. Сообщалось о срабатывании на 5.0 и MG, но оно не гарантировано. Оставьте резервный будильник для важных событий. Отправляет экспериментальные запросы протокола и включает будильник браслета 5/MG. Не требуется для обычной синхронизации, сна, восстановления или подсчёта шагов. + Результат сна обновлён + Время засыпания, пробуждения или общая продолжительность сна этой ночи изменились как минимум на пять минут. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index fd084e1815..0f4cc6ea65 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2815,4 +2815,6 @@ %1$s,%2$s,使用中 已通过实验性 5/MG 命令在手环上设置闹钟。有用户报告 5.0 和 MG 手环能够唤醒,但不保证每次生效。重要事项请保留备用闹钟。 发送实验性协议查询并启用 5/MG 手环闹钟。正常同步、睡眠、恢复和步数功能不需要开启。 + 睡眠结果已更新 + 这晚的入睡时间、醒来时间或总睡眠时长变化了至少五分钟。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 1ac939582c..32be65f38b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2874,4 +2874,6 @@ %1$s, %2$s, in use Armed on the strap with an experimental 5/MG command. Strap-driven wakes have been reported on 5.0 and MG, but are not guaranteed. Keep a backup alarm for anything you cannot miss. Sends experimental protocol queries and enables the 5/MG strap alarm. Not needed for normal sync, sleep, recovery, or steps. + Sleep result updated + This night\'s sleep times or total asleep changed by at least five minutes. diff --git a/android/app/src/test/java/com/noop/ui/SleepResultChangeTest.kt b/android/app/src/test/java/com/noop/ui/SleepResultChangeTest.kt new file mode 100644 index 0000000000..7b6e219615 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/SleepResultChangeTest.kt @@ -0,0 +1,36 @@ +package com.noop.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SleepResultChangeTest { + @Test fun resultChangeSwiftOracle() { + val tracker = SleepResultChangeTracker() + fun sample(scope: String = "strap:source:night", onset: Long = 1000, + wake: Long = 30000, asleep: Double = 420.0, edited: Boolean = false) = + SleepResultSnapshot(scope, onset, wake, asleep, edited) + val actual = mutableListOf() + fun observe(snapshot: SleepResultSnapshot?, ready: Boolean = true) { + actual += tracker.observe(snapshot, ready) + } + observe(sample()) + observe(sample()) + observe(sample("strap:source:night", 1060, 30060, 421.0)) + observe(sample("strap:source:night", 1299, 30299, 424.99)) + observe(sample("strap:source:night", 1300)) + observe(sample("strap:source:night", 1300)) + observe(sample("strap:source:night", 1300, 30300), false) + observe(sample("strap:source:night", 1300, 30300)) + observe(sample("strap:source:night", 1300, 30300, 425.0)) + observe(sample("strap:source:night", 1300, 30300, 420.0)) + observe(sample("other-night")) + observe(sample("other-strap")) + observe(sample("other-strap", 2000, 31000, 430.0, true)) + observe(sample("other-strap", 3000, 32000, 440.0, true)) + observe(null) + observe(sample()) + // Generated by the standalone Swift tracker over the identical sequence. + assertEquals(listOf(false, false, false, false, true, false, false, true, + true, true, false, false, false, false, false, false), actual) + } +}