diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 789352521..72a80a78e 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -22,6 +22,8 @@ import '../data/activity_store.dart'; import '../sync/reset_gate.dart'; import '../models/activity_suggestion.dart'; +import '../state/prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; @@ -3128,6 +3130,18 @@ class DerivationEngine { /// are decided before the write transaction, so the slower one's PARTIAL row /// could land on top of the faster one's complete row. static bool _running = false; + + static int _autoLoggedNaps = 0; + + /// How many naps the opt-in auto-log confirmed since the last call. Logging + /// one queues a durable review job for its day; the day's nap credit only + /// lands once that job runs, so the caller kicks it off after the pass + /// instead of leaving it for the next app resume. + static int takeAutoLoggedNaps() { + final n = _autoLoggedNaps; + _autoLoggedNaps = 0; + return n; + } bool get running => _running; /// Hold or release the process-wide latch, as another pass would. @@ -6450,9 +6464,30 @@ class DerivationEngine { ...await activityStore.reconcile(ActivityKind.workout, blocks.suggestionsToPersist), ...await activityStore.reconcile(ActivityKind.nap, blocks.napSuggestions), ]; + // Opt-in (#625): a clear, settled nap is logged without asking. Never + // on an import pass, which has no user at the other end of "just now". + // Inside its own try, like the prompts below: an optional convenience + // must not mark the day partial. + if (!forceFinalize) { + try { + final since = (await SharedPreferences.getInstance()) + .getInt(Prefs.napAutoLogSinceSec); + _autoLoggedNaps += await activityStore.autoLogNaps( + // 0 is the switch turned back off. + sinceSec: (since ?? 0) > 0 ? since : null, + dataEdgeSec: dataNowSec, + settleSec: ana.napBridgeSec, + current: blocks.napSuggestions, + ); + } catch (e) { + _log('nap auto-log failed for ${day.date}: $e'); + } + } for (final suggestion in newSuggestions) { // The rows are already banked; a failed prompt must not mark the day partial. try { + // A nap logged just above is no longer pending, so [mayNotifyNew] + // already declines it. One that could not be logged yet still asks. if (forceFinalize || !await activityStore.mayNotifyNew(suggestion)) continue; final nap = suggestion.kind == ActivityKind.nap; await NotificationCenter.instance.emit(NotificationEvent( diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 7d5e61862..620f46603 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -459,6 +459,7 @@ class ActivityStore { required int endTs, Map? session, bool edited = false, + bool auto = false, }) => db.transaction((tx) async { final row = await _check(tx, expected, 'confirmed'); if (row == null) return; @@ -520,6 +521,8 @@ class ActivityStore { 'start': startTs, 'end': endTs, 'source': 'confirmed', + // Logged by the opt-in setting, not by a tap (#625). + if (auto) 'auto_logged': true, }), }); await _changed(tx, day); @@ -533,6 +536,83 @@ class ActivityStore { await _changed(tx, row['day_id'] as String); }); + /// The bar for logging a nap without asking (#625). The detector's + /// confidence is a composite of HR coverage, HR dip, stillness and wear, + /// in [0.2, 0.85]; it is not a probability and 0.8 is not a validated cut. + /// ponytail: one fixed bar, the value the issue asked for. Make it a user + /// choice if people want it looser or stricter. + static const double napAutoLogMinConfidence = 0.8; + + /// Whether the opt-in setting would log [s] without asking. [sinceSec] is + /// when the setting was turned on (null = off): a nap that ended before + /// that is never logged behind the user's back. + static bool napAutoLoggable(ActivitySuggestion s, int? sinceSec) { + final c = s.details['confidence']; + return sinceSec != null && + s.kind == ActivityKind.nap && + s.status == ActivityReviewStatus.pending && + c is num && + c >= napAutoLogMinConfidence && + // Strictly after: the switch is stored in whole seconds, and a nap + // that ended in that same second ended before it was turned on. + s.endTs > sinceSec; + } + + /// Confirms every [napAutoLoggable] pending nap exactly as detected, once + /// the data edge is [settleSec] past its end, so sleep that resumes after a + /// short arousal can still be bridged into it first. A nap that cannot be + /// confirmed (it overlaps saved sleep, or changed underneath) stays pending. + /// Returns how many were logged. + /// + /// [current] is the nap candidates this pass detected. Only a pending row + /// that overlaps one of them is a candidate: [reconcile] only retires rows + /// that overlap a re-detection, so a nap a later pass no longer finds stays + /// pending, and must not be confirmed behind the user's back. + Future autoLogNaps({ + required int? sinceSec, + required int dataEdgeSec, + required int settleSec, + required List> current, + }) async { + if (sinceSec == null) return 0; + final spans = <(int, int)>[ + for (final c in current) + ( + ((c['start_ts'] ?? c['start']) as num).toInt(), + ((c['end_ts'] ?? c['end']) as num).toInt(), + ), + ]; + if (spans.isEmpty) return 0; + await migrateLegacy(); + // Imported history is never logged for the user: the same line + // [mayNotifyNew] draws for prompts. + final importedThrough = + (await db.query('activity_review_meta')).single['alert_after_ts'] + as int; + // Not [pending]: its seven-day floor is for what the review list shows. A + // band that syncs after a longer gap still has naps to log. + final rows = await db.query( + 'activity_suggestions', + where: "status = 'pending' AND kind = 'nap' AND end_ts > ?", + whereArgs: [math.max(sinceSec, importedThrough)], + orderBy: 'start_ts ASC, id ASC', + ); + var logged = 0; + for (final s in rows.map(ActivitySuggestion.fromRow)) { + if (!napAutoLoggable(s, sinceSec) || s.endTs + settleSec > dataEdgeSec) { + continue; + } + if (!spans.any((c) => s.startTs < c.$2 && s.endTs > c.$1)) continue; + try { + await confirm(s, startTs: s.startTs, endTs: s.endTs, auto: true); + logged++; + } on ActivityReviewException { + // Left for the user to review. + } + } + return logged; + } + static Future _changed(DatabaseExecutor tx, String day) async { await _ensureMeta(tx); await tx.rawUpdate( diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 80f857027..e0416e107 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -89,6 +89,7 @@ class ImportSummary extends MapView { /// backed-up key missing here is written as-is; the guard test keeps this /// table complete. Keys in [kPrefsNotBackedUp] need no entry. const Map kPrefTypes = { + 'activity.nap_auto_log_since_sec': 'int', 'ai.journal_done_day': 'string', 'ai_evening_enabled': 'bool', 'ai_evening_min': 'int', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 233bed5aa..bea96ee7b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3502,6 +3502,9 @@ "strengthBodyweight": "Körpergewicht", "strengthDelete": "Löschen", "strengthSaveFailed": "Änderungen konnten nicht gespeichert werden", + "napAutoLogTitle": "Eindeutige Nickerchen automatisch eintragen", + "napAutoLogSub": "Ein erkanntes Nickerchen mit starken Anzeichen wird ohne Nachfrage eingetragen. Du kannst es weiterhin bearbeiten oder entfernen", + "napAutoLogSaveFailed": "Die Einstellung konnte nicht gespeichert werden. Versuche es erneut.", "settingsStepCalibration": "Schrittkalibrierung", "settingsStepCalibrationSub": "Lernt, wie stark dein Band deine Schritte zu hoch oder zu niedrig zählt, und korrigiert das auf dem Smartphone. Nichts wird auf das Band geschrieben und nichts verlässt das Gerät", "stepCalTitle": "Schrittkalibrierung", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec5231ef9..9fee91f2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16398,5 +16398,8 @@ "strengthDelete": "Delete", "@strengthDelete": {"description": "Strength set editor copy."}, "strengthSaveFailed": "Failed to save changes", - "@strengthSaveFailed": {"description": "Strength set editor copy."} + "@strengthSaveFailed": {"description": "Strength set editor copy."}, + "napAutoLogTitle": "Log clear naps automatically", + "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it", + "napAutoLogSaveFailed": "That setting could not be saved. Try again." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a236842ec..4ef981610 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2921,6 +2921,9 @@ "strengthSaveFailed": "No se pudieron guardar los cambios", "reportContactLabel": "Contacto (opcional)", "reportContactHint": "Correo o Discord, solo si quieres una respuesta", + "napAutoLogTitle": "Registrar automáticamente las siestas claras", + "napAutoLogSub": "Una siesta detectada con indicios sólidos se añade sin preguntar. Puedes editarla o eliminarla después", + "napAutoLogSaveFailed": "No se pudo guardar el ajuste. Inténtalo de nuevo.", "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada.", "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2875f5709..811a9336e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2892,6 +2892,9 @@ "strengthSaveFailed": "Échec de l'enregistrement des modifications", "reportContactLabel": "Contact (facultatif)", "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse", + "napAutoLogTitle": "Enregistrer automatiquement les siestes nettes", + "napAutoLogSub": "Une sieste détectée avec des indices solides est ajoutée sans demander. Vous pouvez toujours la modifier ou la supprimer", + "napAutoLogSaveFailed": "Le réglage n’a pas pu être enregistré. Réessayez.", "pairBandNotSensor": "Cet appareil est un {label}, c’est-à-dire un bracelet, pas un simple capteur. Rien n’a été enregistré.", "whoopFirmwareWarningTitle": "Utilisateurs WHOOP : ne vous reconnectez pas à l’application WHOOP", "whoopFirmwareWarningAction": "Si vous utilisez OpenStrap ou une autre application open source, ou si vous comptez y passer plus tard, ne reconnectez pas votre bracelet à l’application WHOOP officielle. Elle peut installer des mises à jour du firmware automatiquement. Désinstallez ou désactivez l’application WHOOP pour l’empêcher de mettre à jour votre bracelet.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b9fea6734..6f7f2b5c7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2892,6 +2892,9 @@ "strengthSaveFailed": "परिवर्तन सहेजने में विफल", "reportContactLabel": "संपर्क (वैकल्पिक)", "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं", + "napAutoLogTitle": "स्पष्ट झपकियाँ अपने-आप दर्ज करें", + "napAutoLogSub": "मज़बूत संकेतों वाली पहचानी गई झपकी बिना पूछे जोड़ दी जाती है। आप इसे बाद में भी बदल या हटा सकते हैं", + "napAutoLogSaveFailed": "यह सेटिंग सहेजी नहीं जा सकी। फिर से कोशिश करें।", "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।", "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f25901a16..becc9d30c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7936,6 +7936,9 @@ "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет.", + "napAutoLogTitle": "Автоматически записывать явный дневной сон", + "napAutoLogSub": "Обнаруженный дневной сон с убедительными признаками добавляется без запроса. Его по-прежнему можно изменить или удалить", + "napAutoLogSaveFailed": "Не удалось сохранить настройку. Попробуйте ещё раз.", "pairBandNotSensor": "Это устройство — {label}, то есть браслет, а не простой датчик. Ничего не сохранено.", "@pairBandNotSensor": { "placeholders": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 19e7265dd..2b4b3678a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16357,6 +16357,9 @@ "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้", + "napAutoLogTitle": "บันทึกการงีบที่ชัดเจนโดยอัตโนมัติ", + "napAutoLogSub": "การงีบที่ตรวจพบและมีหลักฐานชัดเจนจะถูกเพิ่มโดยไม่ถาม คุณยังแก้ไขหรือลบได้", + "napAutoLogSaveFailed": "บันทึกการตั้งค่าไม่สำเร็จ ลองอีกครั้ง", "pairBandNotSensor": "อุปกรณ์นั้นคือ {label} ซึ่งเป็นสายรัด ไม่ใช่เซ็นเซอร์ธรรมดา ไม่ได้บันทึกอะไรไว้", "@pairBandNotSensor": { "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 111dc74e2..0feed66b0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2892,6 +2892,9 @@ "strengthSaveFailed": "保存更改失败", "reportContactLabel": "联系方式(可选)", "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写", + "napAutoLogTitle": "自动记录明确的小睡", + "napAutoLogSub": "证据充分的小睡会直接记录,不再询问。之后仍可编辑或删除", + "napAutoLogSaveFailed": "无法保存该设置,请重试。", "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。", "whoopFirmwareWarningTitle": "WHOOP 用户:请勿重新连接 WHOOP 应用", "whoopFirmwareWarningAction": "如果你在使用 OpenStrap 或其他开源应用,或打算以后改用,请不要再把手环连接到 WHOOP 官方应用。它可能会自动安装固件更新。请卸载或停用 WHOOP 应用,以免它更新你的手环。", diff --git a/lib/state/derive_coordinator.dart b/lib/state/derive_coordinator.dart index 234939ac9..989925919 100644 --- a/lib/state/derive_coordinator.dart +++ b/lib/state/derive_coordinator.dart @@ -132,6 +132,12 @@ class DeriveCoordinator { } catch (e) { _log('[derive] session rescore failed: $e'); } + // Naps the opt-in auto-log confirmed during this pass: their review job + // is durable but only runs on a resume or an edit, so the day's nap + // credit would otherwise wait for one of those. + if (DerivationEngine.takeAutoLoggedNaps() > 0) { + unawaited(refreshActivityReviews(retry: true)); + } bumpInsights(); _notify(); // screens re-fetch from the derived store // Same signal, for the surfaces that can't listen: home/lock-screen diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index 5843873ed..34b48b5a2 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -80,6 +80,16 @@ class Prefs { } } + /// [setBoolAcked] for an int: false when there is no storage or the platform + /// refused the write, never a throw. + static Future setIntAcked(String key, int value) async { + try { + return await _sp?.setInt(key, value) ?? false; + } catch (_) { + return false; + } + } + // ── selection keys (one namespace; keep them disjoint) ────────────────────── /// The five-tab bar's index (Home, Health, Nutrition, Workout, Wellness). /// Read once to migrate, never written. @@ -101,6 +111,11 @@ class Prefs { /// list and nothing reads it except the surfaces it reveals. static const String devMode = 'dev.mode'; + /// "Log clear naps automatically" (#625). Absent = off. Holds WHEN it was + /// turned on, in epoch seconds, so only naps that end after that are logged + /// without asking: switching it on never rewrites days already past. + static const String napAutoLogSinceSec = 'activity.nap_auto_log_since_sec'; + /// Per-metric range toggle on the shared MetricScreen (Today/Week/Month/3M). /// Keyed by the metric id so Sleep / Heart / Body each remember independently. static String metricTab(String metric) => 'ui.metric_tab.$metric'; diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 5fd5a0966..f8ee47bda 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -835,6 +835,8 @@ Map extraCases() => { startTs: 1787157000, endTs: 1787164200, revision: 0, status: ActivityReviewStatus.pending, details: const {}), onConfirm: () {}, onEdit: () {}, onDiscard: () {}), + // Inert here: a tap on the real row changes the real setting. + 'nap_auto_log_row': const IgnorePointer(child: NapAutoLogRow()), 'nap_row': const MetricRow(LucideIcons.sun, C.indigo, 'Daytime sleep', '1h 12m', sub: '2 naps · Sunday, 16 August'), diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index d734aeca0..26047aaa7 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -6,8 +6,10 @@ import '../../compute/manual_session.dart' show ManualWindowErrorMessage, ManualWindowException; import '../../compute/nap_edits.dart'; import '../../models/activity_suggestion.dart'; +import '../../state/prefs.dart'; import '../../theme/theme_switcher.dart' show themedRoute; import '../activity/catalogue.dart'; +import '../profile/profile.dart' show SetRow; import '../ui2.dart'; import 'home_screen.dart'; import 'log_workout.dart'; @@ -183,6 +185,7 @@ class _DetectedActivitiesScreenState extends State : () => _act(s, discard: true), ), ), + const NapAutoLogRow(), ], ), ), @@ -193,6 +196,57 @@ class _DetectedActivitiesScreenState extends State } } +/// The opt-in switch for logging clear naps without asking (#625). Off by +/// default. Turning it on stores the moment it was turned on, so only naps +/// that end after that are logged for the user. +class NapAutoLogRow extends StatefulWidget { + const NapAutoLogRow({super.key}); + + @override + State createState() => _NapAutoLogRowState(); +} + +class _NapAutoLogRowState extends State { + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + final on = Prefs.getInt(Prefs.napAutoLogSinceSec, 0) > 0; + return Surface( + child: SetRow( + LucideIcons.moon, + C.domHealth, + l?.napAutoLogTitle ?? 'Log clear naps automatically', + sub: l?.napAutoLogSub ?? + 'A detected nap with strong evidence is added without asking. ' + 'You can still edit or remove it', + value: on ? (l?.stateOn ?? 'On') : (l?.stateOff ?? 'Off'), + chevron: false, + onTap: () => _toggle(c, on), + ), + ); + } + + /// SharedPreferences updates its cache before the platform confirms and + /// never rolls it back, so a refused write would read as On for the rest of + /// the session and be Off again at the next launch. Say so, and put the + /// cache back. + Future _toggle(BuildContext c, bool on) async { + final before = Prefs.getInt(Prefs.napAutoLogSinceSec, 0); + final messenger = ScaffoldMessenger.maybeOf(c); + final text = AppLocalizations.of(c)?.napAutoLogSaveFailed ?? + 'That setting could not be saved. Try again.'; + final ok = await Prefs.setIntAcked( + Prefs.napAutoLogSinceSec, + on ? 0 : DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + if (!ok) { + Prefs.setInt(Prefs.napAutoLogSinceSec, before); + messenger?.showSnackBar(SnackBar(content: Text(text))); + } + if (mounted) setState(() {}); + } +} + /// Routes inherit the app's theme and reduced-motion policy. Route themedActivityRoute(Widget child) => themedRoute((_) => child, name: child.runtimeType.toString()); diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index abf0c9343..aab439255 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -37,6 +37,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart' show whyFromNote; import '../../state/app_state.dart'; import '../ui2.dart'; +import 'detected_activities.dart' show NapAutoLogRow; import 'home_screen.dart' show clockOfTs, hm, repoOf; import 'metric_detail.dart' show dayNavRow, detailScaffold, pickDay; @@ -406,6 +407,8 @@ class _NapsScreenState extends State { color: C.domHealth, onTap: _busy || day == null ? null : () => _log(day), ), + const SizedBox(height: S.x3), + const NapAutoLogRow(), if (d.rejected.isNotEmpty) ...[ Section( l?.napsRemovedSection ?? 'Removed', diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 5d55e803c..f58008777 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -18,6 +18,9 @@ const _mustTravel = { 'backup.cadence', 'gesture_double_tap', 'band.strap_name', 'health_sync', 'phone_steps', 'cycle_tracking_enabled', 'update_checks_enabled', 'nutrition.barcode_lookup', 'share.map_tiles', 'dev.mode', + // The user's opt-in, with when it was given. Restored imports are fenced by + // alert_after_ts, so a restored timestamp cannot log old naps. + 'activity.nap_auto_log_since_sec', 'coach_api', 'coach_model', 'coach_timeout_seconds', 'coach_use_chatgpt', 'ai_morning_enabled', 'ai_morning_min', 'ai_evening_enabled', 'ai_evening_min', diff --git a/test/nap_auto_log_row_test.dart b/test/nap_auto_log_row_test.dart new file mode 100644 index 000000000..6d2b06ffd --- /dev/null +++ b/test/nap_auto_log_row_test.dart @@ -0,0 +1,35 @@ +// #625: the switch on Detected activities. Off by default; turning it on +// stores when it was turned on, turning it off clears that. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:openstrap_edge/ui2/screens/detected_activities.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('off by default, a tap turns it on, another turns it off', + (t) async { + SharedPreferences.setMockInitialValues({}); + await Prefs.ensureLoaded(); + await t.pumpWidget( + const MaterialApp(home: Scaffold(body: NapAutoLogRow())), + ); + expect(find.text('Off'), findsOneWidget); + expect(Prefs.getInt(Prefs.napAutoLogSinceSec, 0), 0); + + final before = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await t.tap(find.text('Log clear naps automatically')); + await t.pump(); + expect(find.text('On'), findsOneWidget); + expect( + Prefs.getInt(Prefs.napAutoLogSinceSec, 0), + greaterThanOrEqualTo(before), + ); + + await t.tap(find.text('Log clear naps automatically')); + await t.pump(); + expect(find.text('Off'), findsOneWidget); + expect(Prefs.getInt(Prefs.napAutoLogSinceSec, 0), 0); + }); +} diff --git a/test/nap_auto_log_test.dart b/test/nap_auto_log_test.dart new file mode 100644 index 000000000..dcd505e1d --- /dev/null +++ b/test/nap_auto_log_test.dart @@ -0,0 +1,212 @@ +// #625: the opt-in "log clear naps automatically" setting. Off, nothing is +// logged without a tap. On, only a pending nap at or above the confidence bar, +// ended after the switch was turned on, and settled behind the data edge, is +// confirmed exactly as detected. Everything else stays a suggestion. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/activity_store.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/models/activity_suggestion.dart'; + +void main() { + late Database db; + late ActivityStore store; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final start = now - 7200; + final end = start + 1800; + const settle = 300; + + Map nap(double confidence) => { + 'start': start, + 'end': end, + 'duration_min': 24, + 'in_bed_min': 30, + 'confidence': confidence, + }; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() async { + await LocalDb.close(); + LocalDb.dbName = 'nap_auto_log_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + db = await LocalDb.instance; + await db.update('activity_review_meta', {'activated_at': start - 1}); + store = ActivityStore(db); + }); + tearDown(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + Future detect(double confidence) async => + (await store.reconcile(ActivityKind.nap, [nap(confidence)])).single; + + Future run({ + int? since, + int? edge, + List>? current, + }) => store.autoLogNaps( + sinceSec: since, + dataEdgeSec: edge ?? now, + settleSec: settle, + current: current ?? [nap(0.85)], + ); + + Future>> logged() => db.query('sleep_nap'); + + test('off: a clear nap stays a suggestion', () async { + final s = await detect(0.85); + await run(since: null); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + + test('on: a clear, settled nap is logged once, as detected', () async { + final s = await detect(0.85); + await run(since: start - 1); + await run(since: start - 1); + final rows = await logged(); + expect(rows, hasLength(1)); + expect(rows.single['start_ts'], start); + expect(rows.single['end_ts'], end); + expect(rows.single['source'], 'confirmed'); + final payload = jsonDecode(rows.single['payload_json'] as String) as Map; + expect(payload['auto_logged'], true); + expect(payload['duration_min'], 24); + expect((await store.get(s.id))!.status, ActivityReviewStatus.confirmed); + }); + + test('reports how many it logged, so the caller can apply the review', + () async { + await detect(0.85); + final first = await store.autoLogNaps( + sinceSec: start - 1, + dataEdgeSec: now, + settleSec: settle, + current: [nap(0.85)], + ); + final again = await store.autoLogNaps( + sinceSec: start - 1, + dataEdgeSec: now, + settleSec: settle, + current: [nap(0.85)], + ); + expect(first, 1); + expect(again, 0); + }); + + test('a tap-confirmed nap is not marked as auto-logged', () async { + final s = await detect(0.85); + await store.confirm(s, startTs: start, endTs: end); + final payload = + jsonDecode((await logged()).single['payload_json'] as String) as Map; + expect(payload.containsKey('auto_logged'), isFalse); + }); + + test('on: a pending nap this pass no longer detects is never logged', + () async { + final s = await detect(0.85); + await run(since: start - 1, current: const []); + // A different nap elsewhere in the day does not vouch for it either. + await run(since: start - 1, current: [ + {'start': end + 7200, 'end': end + 9000, 'confidence': 0.9}, + ]); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + + test('on: a nap below the bar stays a suggestion', () async { + final s = await detect(0.79); + await run(since: start - 1); + expect(await logged(), isEmpty); + expect((await store.get(s.id))!.status, ActivityReviewStatus.pending); + }); + + test('on: waits until the data edge is past the nap by the settle time', + () async { + await detect(0.85); + await run(since: start - 1, edge: end + settle - 1); + expect(await logged(), isEmpty); + await run(since: start - 1, edge: end + settle); + expect(await logged(), hasLength(1)); + }); + + test('on: a nap that ended before the switch was turned on is left alone', + () async { + await detect(0.85); + await run(since: end + 1); + expect(await logged(), isEmpty); + }); + + test('on: a nap that ended in the same second the switch was turned on ' + 'is left alone', () async { + await detect(0.85); + await run(since: end); + expect(await logged(), isEmpty); + }); + + test('on: a nap synced more than a week late is still logged', () async { + final oldStart = now - 9 * 86400; + await db.update('activity_review_meta', {'activated_at': oldStart - 1}); + final old = {...nap(0.85), 'start': oldStart, 'end': oldStart + 1800}; + await store.reconcile(ActivityKind.nap, [old]); + expect(await store.pending(), isEmpty, reason: 'past the review list'); + await run(since: oldStart - 1, current: [old]); + expect(await logged(), hasLength(1)); + }); + + test('on: a nap from imported history is left alone', () async { + await detect(0.85); + await ActivityStore.suppressImportedAlerts(db); + await run(since: start - 1); + expect(await logged(), isEmpty); + }); + + test('on: a nap the user discarded is never logged', () async { + final s = await detect(0.85); + await store.discard(s); + await store.reconcile(ActivityKind.nap, [nap(0.85)]); + await run(since: start - 1); + expect(await logged(), isEmpty); + }); + + test('only pending naps with a measured confidence qualify', () { + ActivitySuggestion s( + ActivityKind kind, + Object? confidence, [ + ActivityReviewStatus status = ActivityReviewStatus.pending, + ]) => ActivitySuggestion( + id: 'x', + kind: kind, + startTs: start, + endTs: end, + revision: 0, + status: status, + details: {'confidence': confidence}, + ); + bool ok(ActivitySuggestion x) => + ActivityStore.napAutoLoggable(x, start - 1); + expect(ok(s(ActivityKind.nap, 0.8)), isTrue); + expect(ok(s(ActivityKind.nap, null)), isFalse); + expect(ok(s(ActivityKind.workout, 0.85)), isFalse); + expect( + ok(s(ActivityKind.nap, 0.85, ActivityReviewStatus.discarded)), + isFalse, + ); + expect( + ActivityStore.napAutoLoggable(s(ActivityKind.nap, 0.85), null), + isFalse, + ); + }); +}