From dd051cde14a087a143db0b6693fd41334949c618 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:45:07 +0530 Subject: [PATCH 01/72] sleep plan spun forever when one of its reads threw the wellness loader made about ten reads with no error handling, so a malformed medication row or a locked database left it on a spinner for good, with no message and no retry. a failed read now shows a card saying the stored data could not be read, with a try again button, on the sleep plan and on the wellness tabs. --- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 8 ++ lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/ui2/screens/wellness_screen.dart | 169 +++++++++++++---------- test/ui2_wellness_load_failure_test.dart | 111 +++++++++++++++ 9 files changed, 229 insertions(+), 71 deletions(-) create mode 100644 test/ui2_wellness_load_failure_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index af43ebfcf..1d555d53a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1477,6 +1477,8 @@ "homeOfSpan": "von {duration}", "homeLoadFailedTitle": "Der heutige Tag konnte nicht geladen werden", "homeLoadFailedBody": "Der gespeicherte Tag konnte nicht geladen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", + "wellnessLoadFailedTitle": "Konnte nicht gelesen werden", + "wellnessLoadFailedBody": "Die gespeicherten Daten konnten nicht gelesen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", "homeTryAgain": "Erneut versuchen", "homeNothingDerivedTitle": "Noch nichts ausgewertet", "homeNothingDerivedBody": "Es wurden noch keine Daten vom Band verarbeitet.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3a9aa3b41..3faf86630 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7320,6 +7320,14 @@ "@homeLoadFailedBody": { "description": "Body of the card shown when today's data failed to load from the database." }, + "wellnessLoadFailedTitle": "This could not be read", + "@wellnessLoadFailedTitle": { + "description": "Title of the card shown when the Wellness screen or the sleep plan failed to load from the database." + }, + "wellnessLoadFailedBody": "Reading your stored data failed. Nothing was deleted — this is a read that went wrong, not missing data.", + "@wellnessLoadFailedBody": { + "description": "Body of the card shown when the Wellness screen or the sleep plan failed to load from the database." + }, "homeTryAgain": "Try again", "@homeTryAgain": { "description": "Button label to retry loading today's data." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1a0ebd222..562ae296c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1442,6 +1442,8 @@ "homeOfSpan": "de {duration}", "homeLoadFailedTitle": "No se pudo leer el día de hoy", "homeLoadFailedBody": "No se pudo cargar el día guardado. No se eliminó nada; fue un error de lectura, no datos perdidos.", + "wellnessLoadFailedTitle": "No se pudo leer", + "wellnessLoadFailedBody": "No se pudieron leer los datos guardados. No se eliminó nada; fue un error de lectura, no datos perdidos.", "homeTryAgain": "Intentar de nuevo", "homeNothingDerivedTitle": "Aún no se ha calculado nada", "homeNothingDerivedBody": "Todavía no se ha procesado ninguna grabación de la correa.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c39d4afe5..2c4a53942 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1442,6 +1442,8 @@ "homeOfSpan": "sur {duration}", "homeLoadFailedTitle": "Impossible de lire les données d'aujourd'hui", "homeLoadFailedBody": "La journée enregistrée n'a pas pu être chargée. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", + "wellnessLoadFailedTitle": "Lecture impossible", + "wellnessLoadFailedBody": "Les données enregistrées n'ont pas pu être lues. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", "homeTryAgain": "Réessayer", "homeNothingDerivedTitle": "Rien n'a encore été calculé", "homeNothingDerivedBody": "Aucun enregistrement du bracelet n'a encore été traité.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a8e5b8f28..44ebad9e1 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1442,6 +1442,8 @@ "homeOfSpan": "{duration} में से", "homeLoadFailedTitle": "आज का डेटा पढ़ा नहीं जा सका", "homeLoadFailedBody": "सहेजा गया दिन लोड नहीं हो सका। कुछ भी हटाया नहीं गया — यह एक रीड एरर है, डेटा खोना नहीं।", + "wellnessLoadFailedTitle": "इसे पढ़ा नहीं जा सका", + "wellnessLoadFailedBody": "आपका सहेजा गया डेटा पढ़ने में विफलता हुई। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", "homeTryAgain": "फिर से कोशिश करें", "homeNothingDerivedTitle": "अभी तक कुछ भी नहीं निकाला गया", "homeNothingDerivedBody": "अभी तक स्ट्रैप की कोई रिकॉर्डिंग प्रोसेस नहीं हुई है।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f0bd81df2..548eeb448 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3350,6 +3350,8 @@ }, "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", + "wellnessLoadFailedTitle": "Не удалось прочитать", + "wellnessLoadFailedBody": "Не удалось прочитать сохранённые данные. Ничего не удалено — это сбой чтения, а не отсутствие данных.", "homeTryAgain": "Повторить", "homeNothingDerivedTitle": "Ночей с оценкой пока нет", "homeNothingDerivedBody": "Наденьте браслет на ночь и откройте приложение утром.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 41898932e..afebb42b5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1442,6 +1442,8 @@ "homeOfSpan": "共 {duration}", "homeLoadFailedTitle": "今日数据读取失败", "homeLoadFailedBody": "已保存的当日数据加载失败。没有任何内容被删除——这是读取出错,不是数据丢失。", + "wellnessLoadFailedTitle": "无法读取", + "wellnessLoadFailedBody": "读取已存储的数据失败。没有删除任何内容——这是一次读取出错,而不是数据缺失。", "homeTryAgain": "重试", "homeNothingDerivedTitle": "尚未计算出任何数据", "homeNothingDerivedBody": "尚未处理任何腕带记录。", diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index a06af8f18..dc50a010a 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -97,6 +97,9 @@ class _WellnessScreenState extends State with RevisionReload { String get _date => todayLabel(); bool _loading = true; + /// The last read threw. Shown as a retryable card rather than a spinner. + bool _failed = false; + /// One journal write at a time. `putJournalMetrics` deletes the day and /// re-inserts it, so two quick taps both read the same day and the second /// erased the first. @@ -161,86 +164,108 @@ class _WellnessScreenState extends State with RevisionReload { final t = beginRead(#wellness); final app = context.read(); final repo = app.repo; - final db = await LocalDb.instance; - - final meds = await MedDb.defs(db); - final doses = await MedDb.dosesForDay(db, _date); - final adherence = await MedDb.adherenceWindow(db, days: 7); - final breathing = await app.breathingHistory(limit: 5); - - var stress = const {}; - var insights = const {}; - var fields = {}; - var driverRows = const []; - var specs = const []; - if (repo != null) { - stress = await repo.getDayStress(_date); - insights = await repo.getInsights(); - // `readiness_glassbox.breakdown`, NOT `.drivers` — drivers is already - // filtered to the inputs that cleared the smallest-worthwhile-change - // gate, so an input that sat inside its usual spread was never in it and - // the screen could not say "and this one did nothing". Same array - // ReadinessDetail renders, so the two agree by construction. - final gb = envValue(insights['readiness_glassbox']); - final bd = gb?['breakdown']; - // The baselines block: centre, spread, delta and MDC per input. Written - // on every derive since long before anything read it. - final heart = await repo.getDayHeart(_date); - final charts = {}; - for (final k in driverChartKeys) { - charts[k] = await repo.getChart(k); + // Ten reads, any of which can throw (a malformed med schedule row, a + // locked database mid-migration). Uncaught, `_loading` never cleared and + // the sleep plan under the Sleep tab spun forever with no way out. + try { + final db = await LocalDb.instance; + + final meds = await MedDb.defs(db); + final doses = await MedDb.dosesForDay(db, _date); + final adherence = await MedDb.adherenceWindow(db, days: 7); + final breathing = await app.breathingHistory(limit: 5); + + var stress = const {}; + var insights = const {}; + var fields = {}; + var driverRows = const []; + var specs = const []; + if (repo != null) { + stress = await repo.getDayStress(_date); + insights = await repo.getInsights(); + // `readiness_glassbox.breakdown`, NOT `.drivers` — drivers is already + // filtered to the inputs that cleared the smallest-worthwhile-change + // gate, so an input that sat inside its usual spread was never in it and + // the screen could not say "and this one did nothing". Same array + // ReadinessDetail renders, so the two agree by construction. + final gb = envValue(insights['readiness_glassbox']); + final bd = gb?['breakdown']; + // The baselines block: centre, spread, delta and MDC per input. Written + // on every derive since long before anything read it. + final heart = await repo.getDayHeart(_date); + final charts = {}; + for (final k in driverChartKeys) { + charts[k] = await repo.getChart(k); + } + driverRows = driverFacts( + breakdown: [ + for (final r in (bd is List ? bd : const [])) + if (r is Map) r.cast(), + ], + baselines: heart['baselines'] is Map + ? (heart['baselines'] as Map).cast() + : null, + charts: charts, + ); + fields = await repo.getJournalMetrics(_date); + specs = await repo.getJournalFields(); } - driverRows = driverFacts( - breakdown: [ - for (final r in (bd is List ? bd : const [])) - if (r is Map) r.cast(), - ], - baselines: heart['baselines'] is Map - ? (heart['baselines'] as Map).cast() - : null, - charts: charts, + // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours + // long and `now - 13 days` lands on the wrong calendar date across one. + final now = DateTime.now(); + final since = dayLabelOf( + DateTime(now.year, now.month, now.day - (_habitDays - 1)), ); - fields = await repo.getJournalMetrics(_date); - specs = await repo.getJournalFields(); + final history = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); + + if (!stillNewest(#wellness, t)) return; + setState(() { + _meds = meds; + _slots = slotsForDay(meds, _date, doses, now: DateTime.now()); + _adherence = adherence; + _breathing = breathing; + _stress = stress; + _insights = insights; + _drivers = driverRows; + _todayFields = {...fields}; + // A habit is a custom field with a ceiling of one — a per-day yes/no. + // That is exactly what journal_field_def already stores, which is why + // there is no habit table. + _habits = [ + for (final s in specs) + if (s.custom && s.max == 1) s, + ]; + _fields = specs; + _habitHistory = history; + _loading = false; + _failed = false; + }); + } catch (_) { + if (stillNewest(#wellness, t)) { + setState(() => (_loading = false, _failed = true)); + } } - // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours - // long and `now - 13 days` lands on the wrong calendar date across one. - final now = DateTime.now(); - final since = dayLabelOf( - DateTime(now.year, now.month, now.day - (_habitDays - 1)), - ); - final history = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); - - if (!stillNewest(#wellness, t)) return; - setState(() { - _meds = meds; - _slots = slotsForDay(meds, _date, doses, now: DateTime.now()); - _adherence = adherence; - _breathing = breathing; - _stress = stress; - _insights = insights; - _drivers = driverRows; - _todayFields = {...fields}; - // A habit is a custom field with a ceiling of one — a per-day yes/no. - // That is exactly what journal_field_def already stores, which is why - // there is no habit table. - _habits = [ - for (final s in specs) - if (s.custom && s.max == 1) s, - ]; - _fields = specs; - _habitHistory = history; - _loading = false; - }); } + Widget _loadFailed(AppLocalizations? l) => StatusCard( + l?.wellnessLoadFailedTitle ?? 'This could not be read', + l?.wellnessLoadFailedBody ?? + 'Reading your stored data failed. Nothing was deleted — this is ' + 'a read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => (_loading = true, _failed = false)); + _load(); + }, + ); + @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); if (widget.sleepPlanOnly) { - return _loading - ? const Center(child: CircularProgressIndicator()) - : _recovery(c); + if (_loading) return const Center(child: CircularProgressIndicator()); + return _failed ? _loadFailed(l) : _recovery(c); } final last = _breathing.isEmpty ? null : _breathing.first; // `select`, not `watch`: AppState ticks at ~1 Hz during a live session and @@ -282,6 +307,8 @@ class _WellnessScreenState extends State with RevisionReload { const SizedBox(height: S.x5), if (_loading) const Center(child: CircularProgressIndicator()) + else if (_failed) + _loadFailed(l) else ...[ // Mind is the only tab here with something to START. The other // four are logs and reviews, and a "begin" card over a medication diff --git a/test/ui2_wellness_load_failure_test.dart b/test/ui2_wellness_load_failure_test.dart new file mode 100644 index 000000000..f56be53a7 --- /dev/null +++ b/test/ui2_wellness_load_failure_test.dart @@ -0,0 +1,111 @@ +// The sleep plan at the bottom of the Sleep tab is `WellnessScreen` in +// `sleepPlanOnly` mode, and its `_load` makes about ten reads (medication, +// breathing, insights, charts, journal). Any one of them throwing used to leave +// `_loading` set for good, so the section showed a spinner forever with no +// error and no way to retry. A read that throws now ends in a retryable card. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Throws on the first [failures] insight reads, then answers with nothing. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + @override + Future> getToday() async => const {}; + + @override + Future> getDayStress(String date) async => const {}; + + @override + Future> getInsights() async { + if (failures > 0) { + failures--; + throw const FormatException('corrupt insights row'); + } + return const {}; + } + + @override + Future> getDayHeart(String date) async => const {}; + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + const {'points': []}; + + @override + Future> getJournalMetrics( + String date) async => + {}; + + @override + Future> getJournalFields() async => const []; +} + +/// `_load` goes to sqflite on a real event loop, so real time has to pass +/// between pumps. Polls until the spinner is gone; false if it never goes. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return true; + } + return false; +} + +Future _pump(WidgetTester t, LocalRepository repo) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold( + body: SingleChildScrollView( + child: WellnessScreen(sleepPlanOnly: true), + ), + ), + ), + )); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a read that throws shows a retryable card, not a spinner', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo); + + expect(await _settle(t), isTrue, + reason: 'the sleep plan never left its spinner'); + expect(find.text('This could not be read'), findsOneWidget); + + await t.tap(find.text('Try again')); + await t.pump(); + expect(await _settle(t), isTrue); + expect(find.text('This could not be read'), findsNothing); + }, timeout: const Timeout(Duration(seconds: 60))); +} From ceeef7445944ce9830f7af165eefcbaadf9c7c8d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:48:04 +0530 Subject: [PATCH 02/72] home stayed on yesterday after midnight once back to today was tapped 'back to today' stored today's date as a literal instead of clearing the switcher, and home is never disposed, so at midnight the tab quietly became a past-day view: no plan, no alarm, and pull-to-refresh stopped syncing. stepping onto today now clears the switcher so today follows the clock. --- lib/ui2/screens/home_screen.dart | 11 ++++++++++- test/ui2_home_day_switcher_test.dart | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index c8b11cb35..ab88b9087 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1518,6 +1518,15 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } +/// What Home's switcher stores for [day]: null when it is today, so "today" +/// keeps following the clock. Home lives in the shell's IndexedStack and is +/// never disposed — a literal today's date stored by "Back to today" turned +/// into yesterday at midnight, and the tab silently became a past-day view +/// (no plan, no alarm, pull-to-refresh no longer synced). +@visibleForTesting +String? homeSwitcherDay(String day, {DateTime? now}) => + day == todayLabel(now) ? null : day; + class _HomeScreenState extends State with RevisionReload { HomeData? _d; bool _loading = true; @@ -1611,7 +1620,7 @@ class _HomeScreenState extends State with RevisionReload { /// already restricts the arrows to [_days] — so this just re-loads for it. void _goDay(String day) { setState(() { - _day = day; + _day = homeSwitcherDay(day); _loading = true; }); _load(); diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 019f503fd..82ff85b3e 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -209,6 +209,17 @@ void main() { expect(find.text('Today could not be read'), findsOneWidget); expect(find.text('Set an alarm'), findsOneWidget); }); + + // Home is never disposed (the shell's IndexedStack), so "Back to today" + // storing today's literal date left the tab pinned to that date once the + // clock crossed midnight. + test('stepping onto today stores "today", not a date that goes stale', () { + final evening = DateTime(2026, 10, 16, 22); + expect(homeSwitcherDay('2026-10-16', now: evening), isNull); + expect(homeSwitcherDay('2026-10-15', now: evening), '2026-10-15'); + expect(homeSwitcherDay(_today), isNull); + expect(homeSwitcherDay(_yesterday), _yesterday); + }); } /// Today has a real reading; every past day is genuinely bare (no bundle at From e9d2e8a095309365b96edc2b67f823dd3768500e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 23:00:39 +0530 Subject: [PATCH 03/72] past days on home showed english and called themselves today the empty card for a past day was hardcoded english, so it ignored the app language. and when a past day had more than one reading missing, the summary card was titled "some of today's data is missing" under that day's date. both now come from the translation files, and a past day gets its own title. --- lib/l10n/app_de.arb | 3 ++ lib/l10n/app_en.arb | 12 ++++++ lib/l10n/app_es.arb | 3 ++ lib/l10n/app_fr.arb | 3 ++ lib/l10n/app_hi.arb | 3 ++ lib/l10n/app_ru.arb | 3 ++ lib/l10n/app_zh.arb | 3 ++ lib/ui2/screens/home_screen.dart | 14 +++++-- test/ui2_home_day_switcher_test.dart | 60 ++++++++++++++++++++++++++++ 9 files changed, 100 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1d555d53a..24b44f4f4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1494,6 +1494,9 @@ "homeProfileSettings": "Profil und Einstellungen", "homeNothingTodayTitle": "Für heute nichts aufgezeichnet", "homeNothingTodayBody": "Die zuletzt ausgewertete Nacht war {day}. Seitdem ist nichts Neues angekommen.", + "homeNoDataThisDayTitle": "Keine Daten für diesen Tag", + "homeNoDataThisDayBody": "An diesem Tag wurde nichts aufgezeichnet.", + "homeSomeDayDataMissingTitle": "Einige Daten dieses Tages fehlen", "homeReadinessNotScoredTitle": "Heute keine Erholung berechnet", "homeReadinessNeedBody": "{need}, um zu wissen, was für dich normal ist.", "homeReadinessNoReason": "Aus den gespeicherten Daten geht nicht hervor, warum.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3faf86630..9bd4402a4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7400,6 +7400,18 @@ } } }, + "homeNoDataThisDayTitle": "No data for this day", + "@homeNoDataThisDayTitle": { + "description": "Today tab, stepped back to a past day: title shown when that day has nothing recorded." + }, + "homeNoDataThisDayBody": "Nothing was recorded on this day.", + "@homeNoDataThisDayBody": { + "description": "Today tab, stepped back to a past day: body shown when that day has nothing recorded." + }, + "homeSomeDayDataMissingTitle": "Some of this day's data is missing", + "@homeSomeDayDataMissingTitle": { + "description": "Today tab, stepped back to a past day: one card listing every missing reading and why." + }, "homeReadinessNotScoredTitle": "Recovery is not scored today", "@homeReadinessNotScoredTitle": { "description": "Title of the card shown when readiness has no score today." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 562ae296c..629ede28b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1457,6 +1457,9 @@ "homeProfileSettings": "Perfil y ajustes", "homeNothingTodayTitle": "Nada registrado hoy", "homeNothingTodayBody": "La última noche que la app puntuó fue el {day}. No ha llegado nada desde entonces.", + "homeNoDataThisDayTitle": "No hay datos de este día", + "homeNoDataThisDayBody": "No se registró nada este día.", + "homeSomeDayDataMissingTitle": "Faltan algunos datos de este día", "homeReadinessNotScoredTitle": "La recuperación no tiene puntuación hoy", "homeReadinessNeedBody": "{need} para saber qué es lo normal en tu caso.", "homeReadinessNoReason": "No hay ningún dato registrado que lo explique.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2c4a53942..4796fbfc1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1451,6 +1451,9 @@ "homeProfileSettings": "Profil et réglages", "homeNothingTodayTitle": "Rien d'enregistré aujourd'hui", "homeNothingTodayBody": "La dernière nuit évaluée par l'application est le {day}. Rien n'est arrivé depuis.", + "homeNoDataThisDayTitle": "Aucune donnée pour ce jour", + "homeNoDataThisDayBody": "Rien n'a été enregistré ce jour-là.", + "homeSomeDayDataMissingTitle": "Certaines données de ce jour manquent", "homeReadinessNotScoredTitle": "La récupération n'est pas évaluée aujourd'hui", "homeReadinessNeedBody": "{need} pour savoir ce qui est normal pour toi.", "homeReadinessNoReason": "Rien n'a été enregistré pour l'expliquer.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 44ebad9e1..ff5466974 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1451,6 +1451,9 @@ "homeProfileSettings": "प्रोफ़ाइल और सेटिंग्स", "homeNothingTodayTitle": "आज के लिए कुछ भी दर्ज नहीं", "homeNothingTodayBody": "इस ऐप ने जिस आखिरी रात को स्कोर किया वह {day} थी। तब से कुछ भी नहीं पहुँचा है।", + "homeNoDataThisDayTitle": "इस दिन का कोई डेटा नहीं", + "homeNoDataThisDayBody": "इस दिन कुछ भी दर्ज नहीं हुआ।", + "homeSomeDayDataMissingTitle": "इस दिन का कुछ डेटा मौजूद नहीं है", "homeReadinessNotScoredTitle": "आज रिकवरी का स्कोर नहीं है", "homeReadinessNeedBody": "आपके लिए सामान्य क्या है, यह जानने के लिए {need}।", "homeReadinessNoReason": "इसे समझाने के लिए कुछ भी दर्ज नहीं है।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 548eeb448..494986a6a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3375,6 +3375,9 @@ "homeProfileSettings": "Профиль и настройки", "homeNothingTodayTitle": "За сегодня ничего не записано", "homeNothingTodayBody": "Последняя оценённая ночь: {day}. С тех пор данные не поступали.", + "homeNoDataThisDayTitle": "Нет данных за этот день", + "homeNoDataThisDayBody": "В этот день ничего не было записано.", + "homeSomeDayDataMissingTitle": "Часть данных за этот день отсутствует", "@homeNothingTodayBody": { "placeholders": { "day": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index afebb42b5..910302050 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1451,6 +1451,9 @@ "homeProfileSettings": "个人资料与设置", "homeNothingTodayTitle": "今天没有记录到数据", "homeNothingTodayBody": "应用评分的最后一晚是 {day}。此后没有收到任何新数据。", + "homeNoDataThisDayTitle": "这一天没有数据", + "homeNoDataThisDayBody": "这一天没有记录到任何数据。", + "homeSomeDayDataMissingTitle": "这一天的部分数据缺失", "homeReadinessNotScoredTitle": "今天的恢复度未评分", "homeReadinessNeedBody": "{need},才能了解你的正常水平。", "homeReadinessNoReason": "没有记录说明原因。", diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index ab88b9087..0bac27789 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1695,9 +1695,9 @@ class _HomeScreenState extends State with RevisionReload { // install's live pipeline catching up, which has nothing to do with a day // the switcher stepped back onto. if (pastDay) { - return const StatusCard( - 'No data for this day', - 'Nothing was recorded on this day.', + return StatusCard( + l?.homeNoDataThisDayTitle ?? 'No data for this day', + l?.homeNoDataThisDayBody ?? 'Nothing was recorded on this day.', fix: '', icon: LucideIcons.calendarOff, ); @@ -2265,7 +2265,13 @@ class _HomeScreenState extends State with RevisionReload { else if (absent.length > 1) ...[ const SizedBox(height: S.x3), StatusCard( - l?.homeSomeDataMissingTitle ?? 'Some of today\'s data is missing', + // The glance also renders for a day the switcher stepped back + // onto, where "today's" names the wrong day. + isToday + ? (l?.homeSomeDataMissingTitle ?? + 'Some of today\'s data is missing') + : (l?.homeSomeDayDataMissingTitle ?? + 'Some of this day\'s data is missing'), [ for (final s in absent) [s.what, s.why, s.fix].where((t) => t.isNotEmpty).join('. '), diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 82ff85b3e..da3b6a291 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/theme/theme_controller.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; @@ -190,6 +191,54 @@ void main() { expect(find.text('Sync the band'), findsNothing); }); + testWidgets('the past-day empty card follows the app language', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _EmptyPastRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + + final l = AppLocalizations.of(t.element(find.byType(HomeScreen)))!; + await t.tap(find.bySemanticsLabel(l.metricDetailPreviousDay)); + await _settle(t); + + expect(find.text('Keine Daten für diesen Tag'), findsOneWidget); + expect(find.text('No data for this day'), findsNothing); + }); + + testWidgets('a past day with gaps does not call itself today', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _PartialPastRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + final title = find.text('Some of this day\'s data is missing'); + await t.scrollUntilVisible(title, 300, + scrollable: find.byType(Scrollable).first); + expect(title, findsOneWidget); + expect(find.text('Some of today\'s data is missing'), findsNothing); + }); + testWidgets('a failed read still shows the alarm door', (t) async { // The alarm lives on AppState, not HomeData, so the error card must not // take it down with it. @@ -252,3 +301,14 @@ class _FailingRepo extends _EmptyPastRepo { @override Future> availableDays() async => throw StateError('read failed'); } + +/// Yesterday has a readiness and a strain but no resting HR and no +/// calories, so the glance has more than one absence to summarise. +class _PartialPastRepo extends _EmptyPastRepo { + @override + Future> getDayOverview(String date) async => + {'readiness': 42}; + @override + Future> getDayStrain(String date) async => + {'strain': 9.9}; +} From 9761836a3c105518cba483ca34c2c46251933b63 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 23:13:54 +0530 Subject: [PATCH 04/72] coach setup ignored a typed model while an old one was saved the model field doubles as a search box, and save preferred the stored selection over anything typed. a user set up with gpt-4o who typed gpt-4.1, or pointed the base url at ollama by hand and typed llama3.1, got the old model saved while the screen closed as if it worked. typing in the field now drops the old selection, a hand-edited endpoint drops the list fetched from the previous one, and the model that will be saved is shown under the field. --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 9 ++++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 8 +++++ lib/l10n/app_zh.arb | 1 + lib/ui2/screens/coach.dart | 40 ++++++++++++++++++++---- test/coach_setup_api_test.dart | 56 ++++++++++++++++++++++++++++++++++ 9 files changed, 112 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 24b44f4f4..20884ec1c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -912,6 +912,7 @@ "coachLocalSub": "In diesem Netzwerk. Nichts verlässt deinen Rechner.", "coachMenuSemantic": "Chats und KI-Einstellungen", "coachModelHint": "Suchen oder ID eingeben", + "coachModelSelected": "Gespeichertes Modell: {model}", "coachModelLabel": "Modell", "coachModelsFound": "{n, plural, one{{n} Modell. Zum Auswählen antippen.} other{{n} Modelle. Zum Auswählen antippen.}}", "coachNavTitle": "Coach", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9bd4402a4..16520a9ad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4424,6 +4424,15 @@ "@coachModelHint": { "description": "Coach AI settings: placeholder hint in the model search field" }, + "coachModelSelected": "Saving model: {model}", + "@coachModelSelected": { + "description": "Coach AI settings: line under the model field naming the model Save will store when nothing is typed", + "placeholders": { + "model": { + "type": "String" + } + } + }, "coachModelLabel": "Model", "@coachModelLabel": { "description": "Coach AI settings: text field label for searching or typing a model id" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 629ede28b..c04ad77d7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -883,6 +883,7 @@ "coachLocalSub": "En esta red. Nada sale de tu máquina.", "coachMenuSemantic": "Chats y ajustes de IA", "coachModelHint": "buscar, o escribir un id", + "coachModelSelected": "Modelo que se guardará: {model}", "coachModelLabel": "Modelo", "coachModelsFound": "{n, plural, one{{n} modelo. Toca uno.} other{{n} modelos. Toca uno.}}", "coachNavTitle": "Coach", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4796fbfc1..af9b87663 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -883,6 +883,7 @@ "coachLocalSub": "Sur ce réseau. Rien ne quitte votre machine.", "coachMenuSemantic": "Discussions et réglages IA", "coachModelHint": "chercher, ou saisir un identifiant", + "coachModelSelected": "Modèle enregistré : {model}", "coachModelLabel": "Modèle", "coachModelsFound": "{n, plural, one{{n} modèle. Touchez-en un.} other{{n} modèles. Touchez-en un.}}", "coachNavTitle": "Coach", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ff5466974..2ce94d605 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -883,6 +883,7 @@ "coachLocalSub": "इसी नेटवर्क पर। आपकी मशीन से कुछ भी बाहर नहीं जाता।", "coachMenuSemantic": "चैट और AI सेटिंग्स", "coachModelHint": "खोजें, या एक आईडी टाइप करें", + "coachModelSelected": "सहेजा जाने वाला मॉडल: {model}", "coachModelLabel": "मॉडल", "coachModelsFound": "{n} मॉडल मिले। किसी एक पर टैप करें।", "coachNavTitle": "कोच", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 494986a6a..9f849b45b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1956,6 +1956,14 @@ "coachLocalSub": "В этой сети. Данные не покидают ваш компьютер.", "coachMenuSemantic": "Чаты и настройки ИИ", "coachModelHint": "найдите или введите идентификатор", + "coachModelSelected": "Будет сохранена модель: {model}", + "@coachModelSelected": { + "placeholders": { + "model": { + "type": "String" + } + } + }, "coachModelLabel": "Модель", "coachModelsFound": "{n, plural, one{Найдена {n} модель. Выберите её.} few{Найдено {n} модели. Выберите одну.} many{Найдено {n} моделей. Выберите одну.} other{Найдено {n} модели. Выберите одну.}}", "@coachModelsFound": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 910302050..d70654f83 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -883,6 +883,7 @@ "coachLocalSub": "在本地网络中运行,不会离开你的设备。", "coachMenuSemantic": "聊天记录与 AI 设置", "coachModelHint": "搜索或输入 ID", + "coachModelSelected": "将保存的模型:{model}", "coachModelLabel": "模型", "coachModelsFound": "共 {n} 个模型,点选其中一个。", "coachNavTitle": "教练", diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 8057a68a6..1e9bf5e0b 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -864,7 +864,12 @@ class _CoachSetupState extends State { late final TextEditingController _search; late final TextEditingController _timeout; late CoachApi _api; + /// The model Save will store when nothing has been typed: the saved one at + /// first, then whatever list row was last tapped. Typing in [_search] + /// clears it (see [_onSearchChanged]) so a typed id is never silently + /// overridden by a selection the form wasn't showing. String _model = ''; + String _lastSearch = ''; List _models = const []; bool _loading = false; String? _msg; @@ -899,11 +904,7 @@ class _CoachSetupState extends State { _keyOrigin = coachEndpointOrigin(cfg.baseUrl); // The base URL decides which preset is lit and whether a key is needed, and // the search box filters the list — both are read during build, so both - // have to rebuild it. - void redraw() { - if (mounted) setState(() {}); - } - + // have to rebuild it (both listeners call setState). _base.addListener(_onBaseChanged); // Deliberately NOT tracked via a _key listener: an early attempt cleared // _pendingKeyDelete as soon as the user typed a replacement, but typing @@ -912,7 +913,23 @@ class _CoachSetupState extends State { // already derives "is there a real replacement right now" correctly by // checking the trimmed text itself; _pendingKeyDelete only needs to say // whether the endpoint changed, not track the field's history. - _search.addListener(redraw); + _search.addListener(_onSearchChanged); + } + + /// The model field is both a filter and a free-text id, and Save prefers + /// [_model]. Left set, a saved 'gpt-4o' would win over anything typed, so a + /// retyped id (or a local model typed after pointing the base URL at + /// Ollama) was dropped while the screen still closed as if it had saved. + /// Tapping a row after filtering doesn't touch the text, so that pick + /// survives. Only a real text edit counts — the controller also notifies on + /// cursor and selection moves. + void _onSearchChanged() { + final text = _search.text; + if (text != _lastSearch) { + _lastSearch = text; + _model = ''; + } + if (mounted) setState(() {}); } /// Clears a carried-over key rather than letting Save silently send it to a @@ -925,6 +942,10 @@ class _CoachSetupState extends State { if (origin != _keyOrigin) { _keyOrigin = origin; _api = CoachApi.defaultFor(_base.text); + // A list fetched from the old endpoint names models the new one may + // not serve. The selected model itself stays, shown under the Model + // field, so a same-provider URL edit doesn't force retyping it. + _models = const []; _pendingKeyDelete = true; if (_key.text.isNotEmpty) _key.clear(); _msg = 'The API key was cleared because the endpoint changed.'; @@ -1195,6 +1216,13 @@ class _CoachSetupState extends State { label: l?.coachModelLabel ?? 'Model', hint: l?.coachModelHint ?? 'search, or type an id', ), + if (_model.isNotEmpty) ...[ + const SizedBox(height: S.x2), + Text( + l?.coachModelSelected(_model) ?? 'Saving model: $_model', + style: F.cap.copyWith(color: p.ink3), + ), + ], const SizedBox(height: S.x1), Builder( builder: (_) => Padding( diff --git a/test/coach_setup_api_test.dart b/test/coach_setup_api_test.dart index 12fa2d5e5..5eda8ebe7 100644 --- a/test/coach_setup_api_test.dart +++ b/test/coach_setup_api_test.dart @@ -201,4 +201,60 @@ void main() { expect(tester.takeException(), isNull); }, ); + testWidgets('a model id typed over a saved model is the one saved', ( + tester, + ) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + // The saved model is visible before anything is typed. + await tester.scrollUntilVisible( + find.text('Saving model: gpt-4o'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Saving model: gpt-4o'), findsOneWidget); + + await _enter(tester, 'Model', 'gpt-4.1'); + expect(find.text('Saving model: gpt-4o'), findsNothing); + await _save(tester); + expect(config.model, 'gpt-4.1'); + expect(tester.takeException(), isNull); + }); + + testWidgets('a hand-edited base URL saves the model typed for it', ( + tester, + ) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + + await _enter(tester, 'Base URL', 'http://192.168.1.20:11434/v1'); + await _enter(tester, 'Model', 'llama3.1'); + await _save(tester); + expect(config.apiBase, 'http://192.168.1.20:11434/v1'); + expect(config.model, 'llama3.1'); + expect(tester.takeException(), isNull); + }); + + testWidgets('saving untouched keeps the saved model', (tester) async { + final config = CoachConfig(); + addTearDown(config.dispose); + await config.save(model: 'gpt-4o', apiKey: 'placeholder-key'); + await _mount(tester, config); + // No field was focused, so there is no keyboard for _save to hide, and + // nothing has scrolled the lazily built Save button into the tree yet. + await tester.scrollUntilVisible( + find.text('Save'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(config.model, 'gpt-4o'); + expect(tester.takeException(), isNull); + }); } From a7d53ee33a16a05205223b19c78e46381be4547b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:33:31 +0530 Subject: [PATCH 05/72] sleep showed tonight's plan under past nights, and twice on the tab the tonight section reads need, debt and bedtime from today's cross-day rollup whichever night is selected, so stepping back two weeks printed today's lights-out under that old night. on the sleep tab the same plan was also in the footer. the section now only shows on the latest night and only on the pushed screen, and the tab's sleep plan footer is dropped when a past night is on screen. --- lib/ui2/screens/sleep_detail.dart | 25 +++++++++++--- lib/ui2/screens/sleep_tab.dart | 4 ++- test/ui2_sleep_detail_test.dart | 56 +++++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index ca3b0b9b0..76333ed2a 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -368,16 +368,22 @@ class SleepDetail extends StatefulWidget { /// The Sleep tab's root rather than a pushed screen: a tab header with the /// profile avatar instead of a back arrow, pull to re-read, and [footer] - /// (the sleep plan, alarm and body clock) under the night. + /// (alarm and body clock) under the night. final bool embedded; final List footer; + /// Rendered above [footer], and only while the night on screen is the + /// latest one. The sleep plan is advice for tonight; under a night stepped + /// back to two weeks ago it read as that night's advice. + final List todayFooter; + const SleepDetail( {super.key, this.data, this.day, this.embedded = false, - this.footer = const []}); + this.footer = const [], + this.todayFooter = const []}); @override State createState() => _SleepDetailState(); @@ -392,6 +398,11 @@ class _SleepDetailState extends State with RevisionReload { @override void reload() => _load(); + /// Whether [day] is today's night (or unknown, which is what a screen with + /// no day yet is showing). Advice about tonight only belongs under this one. + static bool _onLatestNight(String? day) => + (daysBehind(_noonOf(day)) ?? 0) <= 0; + /// The pushed screen's scaffold, or the tab's own header and footer. Widget _frame(BuildContext c, String title, List body, {String sub = ''}) { @@ -411,6 +422,7 @@ class _SleepDetailState extends State with RevisionReload { child: Text(sub, style: F.over.copyWith(color: p.ink3)), ), ...body, + if (_onLatestNight(_d?.day ?? _day)) ...widget.todayFooter, ...widget.footer, ], ), @@ -704,7 +716,7 @@ class _SleepDetailState extends State with RevisionReload { // printed over a night that could be days old. if (unusual != null) Section( - (daysBehind(_noonOf(d.day)) ?? 0) <= 0 + _onLatestNight(d.day) ? (l?.sleepDetailUnusualLastNight ?? 'Unusual last night') : (l?.sleepDetailUnusualOnDay(prettyDay(d.day, l)) ?? 'Unusual on ${prettyDay(d.day, l)}'), @@ -715,7 +727,12 @@ class _SleepDetailState extends State with RevisionReload { _overnight(c, p, d)), // ── 7 · ONE TAKEAWAY ── - Section(l?.sleepDetailTonightSection ?? 'Tonight', _tonight(c, p, d)), + // Tonight's need, debt and bedtime come from today's cross-day rollup + // whichever night is selected, so on a past night they are not that + // night's numbers. The tab carries the full plan in its footer, and + // printing it here too said the same thing twice on one scroll. + if (_onLatestNight(d.day) && !widget.embedded) + Section(l?.sleepDetailTonightSection ?? 'Tonight', _tonight(c, p, d)), const SizedBox(height: S.x5), // The night this screen is steered to, not the newest one. Dropping it diff --git a/lib/ui2/screens/sleep_tab.dart b/lib/ui2/screens/sleep_tab.dart index 50fb74620..193b8ccbc 100644 --- a/lib/ui2/screens/sleep_tab.dart +++ b/lib/ui2/screens/sleep_tab.dart @@ -25,11 +25,13 @@ class SleepTab extends StatelessWidget { final l = AppLocalizations.of(c); return SleepDetail( embedded: true, - footer: [ + todayFooter: [ Section( l?.sleepTabPlanTitle ?? 'Your sleep plan', const WellnessScreen(sleepPlanOnly: true), ), + ], + footer: [ Section( l?.sleepTabMoreTitle ?? 'Alarm and body clock', Column(children: [ diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index d7d3abbfe..0016cf0ae 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; @@ -403,11 +404,8 @@ void main() { find.textContaining('Longest unbroken stretch 2h 42m'), findsOneWidget); }); - testWidgets('tonight is one takeaway, not six numbers', (t) async { - await _pump( - t, - SleepData( - day: '2026-05-20', + SleepData planned(String day) => SleepData( + day: day, night: _night(), need: const Metric( value: 462, unit: 'min', confidence: .7, tier: MetricTier.estimate), @@ -415,13 +413,57 @@ void main() { value: 22, unit: 'min', confidence: .7, tier: MetricTier.estimate), bedtime: const Metric(value: 1360, confidence: .7, tier: MetricTier.estimate), - ), - ); + ); + + testWidgets('tonight is one takeaway, not six numbers', (t) async { + await _pump(t, planned(todayLabel())); expect(find.text('lights out'), findsOneWidget); expect(find.textContaining('Your need is 7h 42m'), findsOneWidget); expect(find.textContaining('22m down'), findsOneWidget); }); + // Need, debt and bedtime are today's rollup whichever night is selected. + testWidgets('a past night carries no plan for tonight', (t) async { + await _pump(t, planned('2026-05-20')); + expect(find.text('lights out'), findsNothing); + expect(find.textContaining('Your need is'), findsNothing); + }); + + group('the Sleep tab', () { + Future tab(WidgetTester t, String day) async { + t.view.physicalSize = const Size(390 * 3, 5200 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: SleepDetail( + embedded: true, + data: planned(day), + todayFooter: const [Text('the sleep plan')], + footer: const [Text('alarm and body clock')], + ), + ), + )); + await t.pumpAndSettle(); + } + + testWidgets('leaves tonight to the plan below instead of saying it twice', + (t) async { + await tab(t, todayLabel()); + expect(find.text('lights out'), findsNothing); + expect(find.text('the sleep plan'), findsOneWidget); + expect(find.text('alarm and body clock'), findsOneWidget); + }); + + testWidgets('drops the plan under a past night, keeps the links', + (t) async { + await tab(t, '2026-05-20'); + expect(find.text('the sleep plan'), findsNothing); + expect(find.text('alarm and body clock'), findsOneWidget); + }); + }); + group('correctedSleepWindow', () { test('a bedtime moved past midnight stays on the same night', () { final (on, off) = correctedSleepWindow( From d2af0892a904655d5062d6700712d387b9dde6e9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:40:07 +0530 Subject: [PATCH 06/72] sleep plan vanished from the landing night whenever a night was held over the plan gate compared the selected night against the calendar, so on a morning before the first sync, or after a gap, the sleep tab opened on the held-over night and dropped the plan and the tonight section even though nobody had stepped anywhere. the gate now compares against the night the screen opens on by default and shows the plan on that night or anything newer. --- lib/ui2/screens/sleep_detail.dart | 44 +++++++++++------ test/ui2_sleep_detail_test.dart | 79 +++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 76333ed2a..1f6a4e67a 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -178,6 +178,14 @@ String _capitalise(String s) => class SleepData { final String? day; + /// The night this screen opens on when nobody asked for one: the night + /// `getToday` served (a held-over night before the first sync of the day + /// or across a gap), else today. Tonight's plan belongs under this night + /// and anything newer, and under nothing older. Comparing [day] against + /// the wall-clock date instead hid the plan on the landing view every + /// morning a night was held over. + final String? latestDay; + /// Every day this install has derived, newest first — what [DayNav] steers /// over. Empty in a fixture, which is why a golden shows no stepper. final List days; @@ -206,6 +214,7 @@ class SleepData { const SleepData({ this.day, + this.latestDay, this.days = const [], this.night = const {}, this.timeline = const {}, @@ -225,6 +234,18 @@ class SleepData { bool get hasNight => night['duration_min'] is num; + /// Whether tonight's need, debt and bedtime belong under this night. They + /// come from today's cross-day rollup whichever night is selected, so on + /// an older night they are not that night's numbers. Without [latestDay] + /// (a fixture) the calendar decides. + bool get isLatestNight { + final d = day; + if (d == null) return true; + final top = latestDay; + if (top != null) return d.compareTo(top) >= 0; + return (daysBehind(_noonOf(d)) ?? 0) <= 0; + } + /// Stage samples for the painter — the segment list resampled onto a fixed /// number of columns so a four-hour segment and a four-minute one stay in /// proportion. @@ -290,12 +311,11 @@ class SleepData { // off overnight and the card said "7h 04m" while this screen answered "No // night to show" about the same tap. final days = await repo.availableDays(); - final day = pickDay( - days, - want, - heldOverNightOf(today) ?? - (today['status'] as Map?)?['today_day']?.toString()); - if (day == null) return SleepData(days: days); + final prefer = heldOverNightOf(today) ?? + (today['status'] as Map?)?['today_day']?.toString(); + final latest = pickDay(days, null, prefer); + final day = pickDay(days, want, prefer); + if (day == null) return SleepData(days: days, latestDay: latest); final night = await repo.getDaySleepV2(day); var deviceNights = const >[]; @@ -339,6 +359,7 @@ class SleepData { return SleepData( day: day, + latestDay: latest, days: days, night: night, timeline: timeline, @@ -398,11 +419,6 @@ class _SleepDetailState extends State with RevisionReload { @override void reload() => _load(); - /// Whether [day] is today's night (or unknown, which is what a screen with - /// no day yet is showing). Advice about tonight only belongs under this one. - static bool _onLatestNight(String? day) => - (daysBehind(_noonOf(day)) ?? 0) <= 0; - /// The pushed screen's scaffold, or the tab's own header and footer. Widget _frame(BuildContext c, String title, List body, {String sub = ''}) { @@ -422,7 +438,7 @@ class _SleepDetailState extends State with RevisionReload { child: Text(sub, style: F.over.copyWith(color: p.ink3)), ), ...body, - if (_onLatestNight(_d?.day ?? _day)) ...widget.todayFooter, + if (_d?.isLatestNight ?? _day == null) ...widget.todayFooter, ...widget.footer, ], ), @@ -716,7 +732,7 @@ class _SleepDetailState extends State with RevisionReload { // printed over a night that could be days old. if (unusual != null) Section( - _onLatestNight(d.day) + (daysBehind(_noonOf(d.day)) ?? 0) <= 0 ? (l?.sleepDetailUnusualLastNight ?? 'Unusual last night') : (l?.sleepDetailUnusualOnDay(prettyDay(d.day, l)) ?? 'Unusual on ${prettyDay(d.day, l)}'), @@ -731,7 +747,7 @@ class _SleepDetailState extends State with RevisionReload { // whichever night is selected, so on a past night they are not that // night's numbers. The tab carries the full plan in its footer, and // printing it here too said the same thing twice on one scroll. - if (_onLatestNight(d.day) && !widget.embedded) + if (d.isLatestNight && !widget.embedded) Section(l?.sleepDetailTonightSection ?? 'Tonight', _tonight(c, p, d)), const SizedBox(height: S.x5), diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index 0016cf0ae..b0c036eb5 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; @@ -404,8 +405,9 @@ void main() { find.textContaining('Longest unbroken stretch 2h 42m'), findsOneWidget); }); - SleepData planned(String day) => SleepData( + SleepData planned(String day, {String? latest}) => SleepData( day: day, + latestDay: latest, night: _night(), need: const Metric( value: 462, unit: 'min', confidence: .7, tier: MetricTier.estimate), @@ -429,8 +431,38 @@ void main() { expect(find.textContaining('Your need is'), findsNothing); }); + // The landing view sits on a held-over night whenever one is served, which + // is a day or more behind the calendar. Tonight's plan still belongs there. + testWidgets('a held-over night the screen opened on keeps tonight', + (t) async { + await _pump(t, planned('2026-05-20', latest: '2026-05-20')); + expect(find.text('lights out'), findsOneWidget); + }); + + testWidgets('a night older than the held-over one carries no plan', + (t) async { + await _pump(t, planned('2026-05-18', latest: '2026-05-20')); + expect(find.text('lights out'), findsNothing); + }); + + group('SleepData.load', () { + test('the newest night on offer is the held-over one, not the calendar', + () async { + final repo = _HeldOverRepo(); + final opened = await SleepData.load(repo); + expect(opened.day, '2026-05-20'); + expect(opened.latestDay, '2026-05-20'); + expect(opened.isLatestNight, isTrue); + + final stepped = await SleepData.load(repo, want: '2026-05-19'); + expect(stepped.day, '2026-05-19'); + expect(stepped.latestDay, '2026-05-20'); + expect(stepped.isLatestNight, isFalse); + }); + }); + group('the Sleep tab', () { - Future tab(WidgetTester t, String day) async { + Future tab(WidgetTester t, String day, {String? latest}) async { t.view.physicalSize = const Size(390 * 3, 5200 * 3); t.view.devicePixelRatio = 3; addTearDown(t.view.reset); @@ -439,7 +471,7 @@ void main() { home: Scaffold( body: SleepDetail( embedded: true, - data: planned(day), + data: planned(day, latest: latest), todayFooter: const [Text('the sleep plan')], footer: const [Text('alarm and body clock')], ), @@ -456,6 +488,12 @@ void main() { expect(find.text('alarm and body clock'), findsOneWidget); }); + testWidgets('keeps the plan on a held-over night it opened on', + (t) async { + await tab(t, '2026-05-20', latest: '2026-05-20'); + expect(find.text('the sleep plan'), findsOneWidget); + }); + testWidgets('drops the plan under a past night, keeps the links', (t) async { await tab(t, '2026-05-20'); @@ -614,3 +652,38 @@ void main() { expect(find.text('I was asleep'), findsNothing); }); } + +/// `getToday` holding the 2026-05-20 night over, with older nights on disk. +class _HeldOverRepo extends LocalRepository { + @override + Future> getToday() async => { + 'status': { + 'today_day': '2026-05-22', + 'showing_prior_overnight': true, + 'overnight_day': '2026-05-20', + }, + }; + + @override + Future> availableDays() async => + ['2026-05-22', '2026-05-20', '2026-05-19']; + + @override + Future> getDaySleepV2(String date) async => {}; + + @override + Future> getDayTimeline(String date) async => {}; + + @override + Future> getInsights() async => {}; + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + {}; + + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + []; +} From 551ec9232e7021312697eed46fb248ed1a798e77 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:53:05 +0530 Subject: [PATCH 07/72] activity tab drew its empty state as fact while the first load ran on the first open of the tab the builder rendered the empty dataset before the load answered, so history said no sessions were recorded, activities asked for a weight already on file, and a start tile tapped in that window opened setup with no weight. the tab now shows a spinner until the first load lands, and a retryable card if it fails. --- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 8 +++ lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/ui2/screens/workout_screen.dart | 36 ++++++++++++-- test/ui2_workout_first_load_test.dart | 72 +++++++++++++++++++++++++++ 9 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 test/ui2_workout_first_load_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 20884ec1c..342aeed18 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2794,6 +2794,8 @@ "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "workoutLoadFailedTitle": "Aktivität konnte nicht gelesen werden", + "workoutLoadFailedBody": "Deine Einheiten konnten nicht geladen werden. Nichts wurde gelöscht – das ist ein fehlgeschlagener Lesevorgang, keine fehlenden Daten.", "notifRecoveryReadyBody": "Erholung {score}.", "notifRecoveryReadySleptBody": "Erholung {score}, geschlafen {hours} h {minutes} min.", "notifStepGoalBody": "Du hast etwa {steps} Schritte erreicht – mindestens dein Ziel von {goal}.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 16520a9ad..a9577b39a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15872,6 +15872,14 @@ "@napsInFuture": { "description": "Validation error when a manually logged nap ends after the current time." }, + "workoutLoadFailedTitle": "Activity could not be read", + "@workoutLoadFailedTitle": { + "description": "Title of the card shown on the Activity tab when the sessions failed to load from the database." + }, + "workoutLoadFailedBody": "Your sessions failed to load. Nothing was deleted — this is a read that went wrong, not missing data.", + "@workoutLoadFailedBody": { + "description": "Body of the card shown on the Activity tab when the sessions failed to load from the database." + }, "notifRecoveryReadyBody": "Recovery {score}.", "@notifRecoveryReadyBody": { "description": "OS notification body when the day's recovery score is ready.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c04ad77d7..82cf5d883 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2698,6 +2698,8 @@ "dataReanalyzeFailed": "El reanálisis falló: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "workoutLoadFailedTitle": "No se pudo leer la actividad", + "workoutLoadFailedBody": "Tus sesiones no se pudieron cargar. No se ha borrado nada: es una lectura que falló, no datos que falten.", "notifRecoveryReadyBody": "Recuperación {score}.", "notifRecoveryReadySleptBody": "Recuperación {score}, dormiste {hours} h {minutes} min.", "notifStepGoalBody": "Llegaste a unos {steps} pasos, al menos tu objetivo de {goal}.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index af9b87663..88b54d088 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2669,6 +2669,8 @@ "dataReanalyzeFailed": "La réanalyse a échoué : {error}", "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "workoutLoadFailedTitle": "Impossible de lire l'activité", + "workoutLoadFailedBody": "Vos séances n'ont pas pu être chargées. Rien n'a été supprimé : c'est une lecture qui a échoué, pas des données manquantes.", "notifRecoveryReadyBody": "Récupération {score}.", "notifRecoveryReadySleptBody": "Récupération {score}, sommeil {hours} h {minutes} min.", "notifStepGoalBody": "Vous avez fait environ {steps} pas, au moins votre objectif de {goal}.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 2ce94d605..f792fadc2 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2669,6 +2669,8 @@ "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "workoutLoadFailedTitle": "गतिविधि पढ़ी नहीं जा सकी", + "workoutLoadFailedBody": "आपके सत्र लोड नहीं हो सके। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", "notifRecoveryReadyBody": "रिकवरी {score}.", "notifRecoveryReadySleptBody": "रिकवरी {score}, नींद {hours} घं {minutes} मि.", "notifStepGoalBody": "आपने लगभग {steps} कदम पूरे किए — आपके {goal} के लक्ष्य जितने या उससे ज़्यादा।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9f849b45b..57289e6e5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7561,6 +7561,8 @@ } }, "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", + "workoutLoadFailedTitle": "Не удалось прочитать активность", + "workoutLoadFailedBody": "Не удалось загрузить ваши тренировки. Ничего не удалено — это ошибка чтения, а не отсутствие данных.", "notifRecoveryReadyBody": "Восстановление {score}.", "@notifRecoveryReadyBody": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d70654f83..3708c0fcd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2669,6 +2669,8 @@ "dataReanalyzeFailed": "重新分析失败:{error}", "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "workoutLoadFailedTitle": "无法读取活动", + "workoutLoadFailedBody": "你的训练记录加载失败。没有删除任何内容——这是读取出错,不是数据缺失。", "notifRecoveryReadyBody": "恢复 {score}。", "notifRecoveryReadySleptBody": "恢复 {score},睡眠 {hours} 小时 {minutes} 分钟。", "notifStepGoalBody": "你走了约 {steps} 步,达到或超过 {goal} 步的目标。", diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index d7a6d19d5..92c2e102c 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -90,6 +90,36 @@ class _WorkoutScreenState extends State with RevisionReload { builder: (c, snap) { final loc = AppLocalizations.of(c); final d = snap.data ?? const _WorkoutData.empty(); + // The first open of the tab has no data yet, and the empty dataset is + // not "no data": drawn as fact it said "No sessions recorded yet" to + // someone with forty, asked for a weight already on file, and handed + // a null weight to any setup screen tapped open in that window. Only + // the first load gets here — a reload keeps the previous snapshot. + final List body; + if (snap.hasData) { + body = switch (tab) { + 0 => _forYou(c, d), + 1 => _activities(c, d), + _ => _history(c, d), + }; + } else if (snap.hasError) { + body = [ + StatusCard( + loc?.workoutLoadFailedTitle ?? 'Activity could not be read', + loc?.workoutLoadFailedBody ?? + 'Your sessions failed to load. Nothing was deleted — this ' + 'is a read that went wrong, not missing data.', + icon: LucideIcons.triangleAlert, + fix: loc?.homeTryAgain ?? 'Try again', + onFix: reload, + ), + ]; + } else { + body = const [ + SizedBox(height: S.x8), + Center(child: CircularProgressIndicator()), + ]; + } // THE LIST DROPS ITS SIDE PADDING and hands it to each child instead, // so the hero card can be the one child that does not get it and runs // edge to edge. Two earlier attempts had the CARD escape its parent — @@ -106,11 +136,7 @@ class _WorkoutScreenState extends State with RevisionReload { SubTabs(_tabs(loc), tab, (i) => setState(() => tab = i), color: C.domMove), const SizedBox(height: S.x5), - ...switch (tab) { - 0 => _forYou(c, d), - 1 => _activities(c, d), - _ => _history(c, d), - }, + ...body, ]) if (w is StartCard) w diff --git a/test/ui2_workout_first_load_test.dart b/test/ui2_workout_first_load_test.dart new file mode 100644 index 000000000..c4da59178 --- /dev/null +++ b/test/ui2_workout_first_load_test.dart @@ -0,0 +1,72 @@ +// The Activity tab's first open, before its one long load has answered. +// +// The bug this pins: the builder drew the EMPTY dataset while the load was +// still running, so a user with sessions and a weight on file was told "No +// sessions recorded yet" on History and asked to add a weight on Activities, +// and a start tile tapped in that window opened setup with a null weight. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/screens/start_card.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// A repo whose first read never answers — the slow, sync-contended launch. +class _StalledRepo extends LocalRepository { + final profile = Completer>(); + @override + Future> getProfile() => profile.future; +} + +Widget _app(AppState app) => MaterialApp( + theme: buildTheme(Brightness.light), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: LocaleController.seed(null), + ), + ], + child: const Scaffold(body: WorkoutScreen()), + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a load in flight is not drawn as an empty history', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _StalledRepo(); + + await t.pumpWidget(_app(app)); + await t.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // No start tiles to tap with a weight that has not been read yet. + expect(find.byType(StartCard), findsNothing); + + await t.tap(find.text('Activities')); + await t.pump(); + expect(find.text('Calorie estimates need your weight'), findsNothing); + + await t.tap(find.text('History')); + await t.pump(); + expect(find.text('No sessions recorded yet'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); +} From b0b81ddd7aba5e5e4e35c15988dc904bf6a607cc Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:35:32 +0530 Subject: [PATCH 08/72] onboarding import report vanished before it was drawn when a first-run import landed rows, welcome finished onboarding right after setting the outcome, so the gate swapped to pairing in the same frame and the report never showed. refused rows, corrupt tables, stranded days and the 're-analyze everything' rollup failure all went unseen. now a clean import still moves straight on, but one whose report has something refused or half-done stays on welcome with a continue button under the report. (cherry picked from commit fe031cef4b92346a7df37f73b1116ddba85cfd25) --- lib/ui2/onboarding/welcome.dart | 69 +++++++++++++--- test/welcome_import_report_hold_test.dart | 97 +++++++++++++++++++++++ 2 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 test/welcome_import_report_hold_test.dart diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index a48fdeb0e..e13bf9d95 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -113,6 +113,15 @@ class ImportOutcome { corruptTables.isNotEmpty || archiveBucketsSkipped > 0; + /// Rows landed, but part of the import was refused or left half-done. On a + /// first run this report is the only place the user ever hears about it, so + /// the gate waits for them instead of moving on to pairing. + bool get needsReading => + lostSomething || + rejectedRows.isNotEmpty || + readError != null || + rollupError != null; + /// Nothing at all landed. A zero under a green tick is a no-op that reads as /// a success, which is the one thing an import report must never do. bool get nothingLanded => @@ -254,6 +263,11 @@ class _WelcomeScreenState extends State { bool _busy = false; ImportOutcome? _outcome; + /// Rows landed but the report has something to say. Onboarding is finished + /// only once the user moves on, because finishing it swaps this screen for + /// pairing in the same frame and the report is never drawn. + bool _holding = false; + Future _import() async { final app = context.read(); FilePickerResult? picked; @@ -276,16 +290,18 @@ class _WelcomeScreenState extends State { setState(() { _busy = true; _outcome = null; + _holding = false; }); try { final outcome = await runImport(app, paths, askPassphrase: () => askBackupPassphrase(context)); if (!mounted) return; - setState(() => _outcome = outcome); - // Anything that landed counts as bringing history in — a workouts-only - // vendor export writes sessions and no days, and the gate used to sit - // there as though the import had not happened. - if (!outcome.nothingLanded) await app.completeImportOnboard(); + final hold = await settleOnboardImport(app, outcome); + if (!mounted) return; + setState(() { + _outcome = outcome; + _holding = hold; + }); } on PassphraseCancelled { // Closing the prompt is a decision, not a failure. Say nothing. } catch (e) { @@ -299,12 +315,31 @@ class _WelcomeScreenState extends State { } @override - Widget build(BuildContext c) => WelcomeView( - busy: _busy, - outcome: _outcome, - onNew: () => context.read().chooseNewUser(), - onImport: _import, - ); + Widget build(BuildContext c) { + final app = context.read(); + return WelcomeView( + busy: _busy, + outcome: _outcome, + // History already landed, so "Set up my band" from here is the same + // step as Continue, and the choice on record stays 'imported'. + onNew: _holding ? app.completeImportOnboard : app.chooseNewUser, + onImport: _import, + onContinue: _holding ? app.completeImportOnboard : null, + ); + } +} + +/// Finish onboarding after an import, unless the report has to be read first. +/// Returns true when it held back so the caller can offer Continue. +/// +/// Anything that landed counts as bringing history in — a workouts-only +/// vendor export writes sessions and no days, and the gate used to sit there +/// as though the import had not happened. +Future settleOnboardImport(AppState app, ImportOutcome outcome) async { + if (outcome.nothingLanded) return false; + if (outcome.needsReading) return true; + await app.completeImportOnboard(); + return false; } /// Route [paths] to the importer that understands them and normalise the @@ -562,12 +597,17 @@ class WelcomeView extends StatelessWidget { final VoidCallback onNew; final VoidCallback onImport; + /// Shown under the report when the import landed but the report has to be + /// read before the gate moves on to pairing. + final VoidCallback? onContinue; + const WelcomeView({ super.key, required this.onNew, required this.onImport, this.busy = false, this.outcome, + this.onContinue, }); @override @@ -632,6 +672,13 @@ class WelcomeView extends StatelessWidget { if (o != null) ...[ const SizedBox(height: S.x6), ImportReport(o), + if (onContinue != null) ...[ + const SizedBox(height: S.x4), + BigButton(l?.actionContinue ?? 'Continue', + icon: LucideIcons.arrowRight, + color: C.green, + onTap: busy ? null : onContinue), + ], ], ], ), diff --git a/test/welcome_import_report_hold_test.dart b/test/welcome_import_report_hold_test.dart new file mode 100644 index 000000000..d14ffc56f --- /dev/null +++ b/test/welcome_import_report_hold_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:openstrap_edge/app.dart' show resolveRoute; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; + +// The gate's welcome/pairing switch, on the real AppState.route and +// resolveRoute. Finishing onboarding swaps WelcomeView out in the same frame, +// which is how a lossy import's report used to vanish before it was drawn. +class _Gate extends StatelessWidget { + final ImportOutcome outcome; + final bool holding; + const _Gate(this.outcome, {required this.holding}); + @override + Widget build(BuildContext context) { + final app = context.read(); + final raw = context.select((a) => a.route); + final r = resolveRoute(raw, pairingSkipped: false, profileSeen: false); + return switch (r) { + AppRoute.welcome => WelcomeView( + onNew: () {}, + onImport: () {}, + outcome: outcome, + onContinue: holding ? app.completeImportOnboard : null), + _ => Scaffold(body: Text('PAIRING:${r.name}')), + }; + } +} + +Future _fresh() async { + SharedPreferences.setMockInitialValues({}); + return AppState()..initialized = true; +} + +void main() { + testWidgets('a lossy onboarding import stays on its report until Continue', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome( + source: 'Vendor CSV export', + days: 30, + rejectedRows: ['line 4: bad date'], + rollupError: 'boom'); + final hold = await settleOnboardImport(app, o); + expect(hold, isTrue); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, child: MaterialApp(home: _Gate(o, holding: hold)))); + await tester.pump(); + expect(app.route, AppRoute.welcome); + expect(find.byType(ImportReport), findsOneWidget); + expect(find.textContaining('Re-analyze everything'), findsOneWidget); + + await tester.scrollUntilVisible(find.text('Continue'), 200); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(app.route, AppRoute.pairing); + expect(find.text('PAIRING:pairing'), findsOneWidget); + }); + + testWidgets('a clean onboarding import moves straight on to pairing', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome(source: 'OpenStrap backup', days: 30); + expect(await settleOnboardImport(app, o), isFalse); + expect(app.route, AppRoute.pairing); + }); + + testWidgets('an import that landed nothing leaves onboarding open', + (tester) async { + final app = await _fresh(); + addTearDown(app.dispose); + const o = ImportOutcome( + source: 'Vendor CSV export', rejectedRows: ['line 2: bad date']); + expect(await settleOnboardImport(app, o), isFalse); + expect(app.route, AppRoute.welcome); + }); + + test('every part of the report that says something was refused holds', () { + const base = ImportOutcome(source: 's', days: 1); + expect(base.needsReading, isFalse); + for (final o in const [ + ImportOutcome(source: 's', days: 1, rejectedRows: ['x']), + ImportOutcome(source: 's', days: 1, readError: 'x'), + ImportOutcome(source: 's', days: 1, rollupError: 'x'), + ImportOutcome(source: 's', days: 1, lateRows: 1), + ImportOutcome(source: 's', days: 1, strandedDays: 1), + ImportOutcome(source: 's', days: 1, corruptTables: {'t'}), + ImportOutcome(source: 's', days: 1, archiveBucketsSkipped: 1), + ]) { + expect(o.needsReading, isTrue); + } + }); +} From dd9ee0698a7f96bd426364e7f383ccf4c4ff5c66 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 12:35:37 +0530 Subject: [PATCH 09/72] journal save and load hung forever on a database error a throw from any of the journal reads or writes escaped unhandled. on load the form sat on its spinner for good; on save the button stayed on "Saving" with no message, and backing out lost the note while the metrics were already written. a failed load now shows a message with a retry, and a failed save re-enables the button and says the entry wasn't saved, keeping the form as is so a second tap writes it. (cherry picked from commit beb2c573c963d83bed04912e492a5781d63544bc) --- lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 12 +++ lib/l10n/app_es.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/ui2/screens/journal_compose.dart | 83 ++++++++++++++++++-- test/journal_compose_error_test.dart | 110 +++++++++++++++++++++++++++ 9 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 test/journal_compose_error_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 342aeed18..e79801b12 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -768,6 +768,9 @@ "dayTimelinePatternsNote": "Muster in deinen eigenen Daten, keine Ursachen. Stehen hier zwei Dinge nebeneinander, sind sie zeitlich nah beieinander passiert – mehr behauptet diese Seite nicht.", "journalComposeNotReady": "Noch nicht bereit – öffne zuerst die App.", "journalComposeSaveFailed": "Konnte nicht gespeichert werden – prüfe den Speicherplatz und versuch es erneut.", + "journalComposeEntrySaveFailed": "Nicht gespeichert – dein Eintrag ist noch da. Versuch es erneut.", + "journalComposeLoadFailed": "Das Journal für diesen Tag konnte nicht geöffnet werden.", + "journalComposeTryAgain": "Erneut versuchen", "journalComposeWhenWasLastOne": "Wann war das letzte Mal?", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Heute", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a9577b39a..e2c249111 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3680,6 +3680,18 @@ "@journalComposeSaveFailed": { "description": "Snackbar shown when saving a new custom journal field fails." }, + "journalComposeEntrySaveFailed": "Not saved — your entry is still here. Try again.", + "@journalComposeEntrySaveFailed": { + "description": "Snackbar shown when saving the day's journal entry fails; the screen stays open with the entry intact." + }, + "journalComposeLoadFailed": "Could not open this day's journal.", + "@journalComposeLoadFailed": { + "description": "Shown in place of the journal form when reading the day's entry fails." + }, + "journalComposeTryAgain": "Try again", + "@journalComposeTryAgain": { + "description": "Button that retries loading the journal entry after a failure." + }, "journalComposeWhenWasLastOne": "When was the last one?", "@journalComposeWhenWasLastOne": { "description": "Time picker help text when logging the time of the last dose of a journal field." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 82cf5d883..107b8dc21 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -739,6 +739,9 @@ "dayTimelinePatternsNote": "Patrones en tus propios registros, no causas. Que dos cosas aparezcan cerca aquí solo significa que ocurrieron cerca en el tiempo.", "journalComposeNotReady": "Aún no está listo — abre la app primero.", "journalComposeSaveFailed": "No se pudo guardar — comprueba el almacenamiento e inténtalo de nuevo.", + "journalComposeEntrySaveFailed": "No se guardó — tu entrada sigue aquí. Inténtalo de nuevo.", + "journalComposeLoadFailed": "No se pudo abrir el diario de este día.", + "journalComposeTryAgain": "Reintentar", "journalComposeWhenWasLastOne": "¿Cuándo fue la última vez?", "journalComposeTitle": "Diario", "journalComposeTodaySection": "Hoy", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 88b54d088..af67f9b3e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -739,6 +739,9 @@ "dayTimelinePatternsNote": "Des motifs dans vos propres données, pas des causes. Deux éléments proches ici se sont simplement produits près l'un de l'autre dans le temps, c'est tout ce que cette page affirme.", "journalComposeNotReady": "Pas encore prêt — ouvrez d'abord l'application.", "journalComposeSaveFailed": "Impossible d'enregistrer — vérifiez le stockage et réessayez.", + "journalComposeEntrySaveFailed": "Non enregistré — votre saisie est toujours là. Réessayez.", + "journalComposeLoadFailed": "Impossible d'ouvrir le journal de ce jour.", + "journalComposeTryAgain": "Réessayer", "journalComposeWhenWasLastOne": "Quand était la dernière fois ?", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Aujourd'hui", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f792fadc2..7a2e061fc 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -739,6 +739,9 @@ "dayTimelinePatternsNote": "यह आपके अपने रिकॉर्ड में पैटर्न है, कारण नहीं। यहाँ पास-पास दिखने वाली दो चीज़ों का मतलब सिर्फ़ इतना है कि वे समय में करीब हुईं — यह पेज बस इतना ही बताता है।", "journalComposeNotReady": "अभी तैयार नहीं है — पहले ऐप खोलें।", "journalComposeSaveFailed": "सेव नहीं हो सका — स्टोरेज जाँचें और फिर से कोशिश करें।", + "journalComposeEntrySaveFailed": "सेव नहीं हुआ — आपकी एंट्री अभी भी यहीं है। फिर से कोशिश करें।", + "journalComposeLoadFailed": "इस दिन का जर्नल नहीं खुल सका।", + "journalComposeTryAgain": "फिर से कोशिश करें", "journalComposeWhenWasLastOne": "पिछली बार कब हुआ था?", "journalComposeTitle": "जर्नल", "journalComposeTodaySection": "आज", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 57289e6e5..03601ae2a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1586,6 +1586,9 @@ "dayTimelinePatternsNote": "Это совпадения в ваших записях, а не причины. Соседние события лишь произошли примерно в одно время — большего эта страница не утверждает.", "journalComposeNotReady": "Ещё не готово — сначала откройте приложение.", "journalComposeSaveFailed": "Не удалось сохранить. Проверьте свободное место и повторите.", + "journalComposeEntrySaveFailed": "Не сохранено — запись осталась на экране. Повторите попытку.", + "journalComposeLoadFailed": "Не удалось открыть дневник за этот день.", + "journalComposeTryAgain": "Повторить", "journalComposeWhenWasLastOne": "Когда это было в последний раз?", "journalComposeTitle": "Дневник", "journalComposeTodaySection": "Сегодня", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3708c0fcd..90a2d56d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -739,6 +739,9 @@ "dayTimelinePatternsNote": "这是你自己记录中的规律,不是因果关系。这里相邻出现的两件事只是时间上接近,本页面仅说明这一点。", "journalComposeNotReady": "尚未就绪 —— 请先打开应用。", "journalComposeSaveFailed": "保存失败 —— 请检查存储空间后重试。", + "journalComposeEntrySaveFailed": "未保存 —— 你的记录仍在此处,请重试。", + "journalComposeLoadFailed": "无法打开这一天的日志。", + "journalComposeTryAgain": "重试", "journalComposeWhenWasLastOne": "上一次是什么时候?", "journalComposeTitle": "日志", "journalComposeTodaySection": "今天", diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 6c565f672..00fc0ed09 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -21,6 +21,7 @@ import '../../coach/coach_config.dart'; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart'; +import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; import '../../state/units_controller.dart'; @@ -51,6 +52,7 @@ class _JournalComposeState extends State { Map _loadedValues = const {}; Set _loadedTags = const {}; bool _loading = true; + bool _loadFailed = false; bool _saving = false; @override @@ -71,9 +73,24 @@ class _JournalComposeState extends State { setState(() => _loading = false); return; } - final specs = await repo.getJournalFields(); - final values = await repo.getJournalMetrics(_date); - final entries = await repo.getJournal(range: '30d'); + final List specs; + final Map values; + final List> entries; + try { + specs = await repo.getJournalFields(); + values = await repo.getJournalMetrics(_date); + entries = await repo.getJournal(range: '30d'); + } catch (_) { + // A locked or swapped-out database used to leave the form on its + // spinner for good. Say so and offer a retry instead. + if (mounted) { + setState(() { + _loading = false; + _loadFailed = true; + }); + } + return; + } Map? today; for (final e in entries) { if (e['date'] == _date) today = e; @@ -87,6 +104,7 @@ class _JournalComposeState extends State { _loadedTags = {..._tags}; _note.text = (today?['note'] as String?) ?? ''; _loading = false; + _loadFailed = false; }); } @@ -207,6 +225,31 @@ class _JournalComposeState extends State { final repo = context.read().repo; if (repo == null) return; setState(() => _saving = true); + try { + await _write(repo); + } catch (_) { + // Any throw here used to escape unhandled and leave Save spinning with + // no message. The form keeps the user's edits so a retry writes them; + // the retry re-reads the day, so metrics a failed run already wrote are + // not doubled. + if (!mounted) return; + setState(() => _saving = false); + final l = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l?.journalComposeEntrySaveFailed ?? + 'Not saved — your entry is still here. Try again.')), + ); + return; + } + if (!mounted) return; + // Today's "How was today?" is already armed; a rated day moves it to + // tomorrow, and only a re-arm does that. + unawaited(context.read().refreshAiReminders()); + Navigator.of(context).pop(true); + } + + Future _write(LocalRepository repo) async { // Both writes REPLACE the day, and the strap's double-tap (a glass of // water, a moment tag) writes the same day while this screen is open. // Re-read and lay only this screen's own edits over it, or Save puts the @@ -232,11 +275,6 @@ class _JournalComposeState extends State { ..removeAll(_loadedTags.difference(_tags)) ..addAll(_tags); await repo.postJournal(_date, tags.toList(), _note.text.trim()); - if (!mounted) return; - // Today's "How was today?" is already armed; a rated day moves it to - // tomorrow, and only a re-arm does that. - unawaited(context.read().refreshAiReminders()); - Navigator.of(context).pop(true); } @override @@ -253,6 +291,35 @@ class _JournalComposeState extends State { Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) + : _loadFailed + ? Center( + child: Padding( + padding: const EdgeInsets.all(S.x6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l?.journalComposeLoadFailed ?? + "Could not open this day's journal.", + textAlign: TextAlign.center, + ), + const SizedBox(height: S.x4), + BigButton( + l?.journalComposeTryAgain ?? 'Try again', + icon: LucideIcons.refreshCw, + color: C.domMind, + onTap: () { + setState(() { + _loading = true; + _loadFailed = false; + }); + _load(); + }, + ), + ], + ), + ), + ) : ListView( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x8), children: [ diff --git a/test/journal_compose_error_test.dart b/test/journal_compose_error_test.dart new file mode 100644 index 000000000..2fadb331c --- /dev/null +++ b/test/journal_compose_error_test.dart @@ -0,0 +1,110 @@ +// A database error during the journal's load or save used to escape +// unhandled: the form stayed on its spinner, or Save stayed on "Saving" with +// no message, and backing out lost the note. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +class _Repo extends LocalRepository { + bool failLoad = false; + bool failJournalWrite = false; + Map metrics = {}; + List tags = []; + String note = ''; + int journalWrites = 0; + + @override + Future> getJournalFields() async { + if (failLoad) throw Exception('database is locked'); + return const []; + } + + @override + Future> getJournalMetrics(String date) async => + {...metrics}; + @override + Future postJournalMetrics( + String date, + Map values, + ) async => + metrics = {...values}; + @override + Future>> getJournal({String range = '30d'}) async => + [ + {'date': '2026-09-01', 'tags': [...tags], 'note': note}, + ]; + @override + Future postJournal(String date, List t, String n) async { + if (failJournalWrite) throw Exception('database is locked'); + journalWrites++; + tags = [...t]; + note = n; + } +} + +Future _pump(WidgetTester t, _Repo repo) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: JournalCompose(date: '2026-09-01')), + ), + )); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a failed save says so, re-enables Save and keeps the note', + (t) async { + final repo = _Repo()..failJournalWrite = true; + await _pump(t, repo); + + await t.enterText(find.byType(TextField).last, 'long day'); + await t.tap(find.text('Save').first); + await t.pumpAndSettle(); + + expect(t.takeException(), isNull); + expect(find.text('Saving'), findsNothing); + expect(find.text('Save'), findsWidgets); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.byType(JournalCompose), findsOneWidget); + expect(find.text('long day'), findsOneWidget); + + // The retry lands the note. + repo.failJournalWrite = false; + await t.tap(find.text('Save').first); + await t.pumpAndSettle(); + expect(repo.journalWrites, 1); + expect(repo.note, 'long day'); + }); + + testWidgets('a failed load offers a retry instead of spinning', (t) async { + final repo = _Repo()..failLoad = true; + await _pump(t, repo); + + expect(t.takeException(), isNull); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Try again'), findsOneWidget); + + repo.failLoad = false; + await t.tap(find.text('Try again')); + await t.pumpAndSettle(); + expect(find.text('Try again'), findsNothing); + expect(find.text('Save'), findsWidgets); + }); +} From dd517c85fba74b67d4e681bcc9dd2c78671a839f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:17:51 +0530 Subject: [PATCH 10/72] past-day home tiles for heart rate, steps and active energy opened today's numbers the glance tiles show the day the switcher is on, but each one opened the trend screen, which always starts on today's window. tapping a past day's 11,204 steps landed on today's count, or on 'nothing recorded today'. on a past day those tiles now open the same per-day screen a point on the trend chart opens (the steps breakdown, or nerd stats for heart rate and energy) for the day being shown. (cherry picked from commit 47b937da69e2550f53ea8c98e40ad2e9cad4573e) --- lib/ui2/screens/home_screen.dart | 12 +++++-- lib/ui2/screens/metric_detail.dart | 34 +++++++++++--------- test/ui2_home_day_switcher_test.dart | 47 ++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 0bac27789..c6287dd28 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -2152,6 +2152,12 @@ class _HomeScreenState extends State with RevisionReload { // was held over from an older night. It cannot be an older night any more // — the loader refuses those — so every tile in this row is today's and // none of them needs a date. + // + // On a past day every tile is THAT day's, and the trend screen opens on + // today's window — tapping "11,204" landed on today's count. A past day's + // tile opens the screen for the day it is showing instead. + Widget detail(String key) => + isToday || _day == null ? MetricDetail(key) : metricDayScreen(key, _day!); // Sleep is a RING now, duration and all — the card here was the same // number twice on one screen, and the ring is the one that says what the @@ -2162,7 +2168,7 @@ class _HomeScreenState extends State with RevisionReload { '${d.rhr.value!.round()}', unit: 'bpm', sub: l?.homeRestingSub ?? 'Resting', - onTap: () => go(c, const MetricDetail('resting_hr'))), + onTap: () => go(c, detail('resting_hr'))), // "no sleep was recorded" was stated as fact, unconditionally — and it // was rendered directly beside a Sleep card showing that night's // duration. Sleep duration and nocturnal RHR are gated separately: a @@ -2202,7 +2208,7 @@ class _HomeScreenState extends State with RevisionReload { '${((d.steps.value! / d.stepGoal) * 100).clamp(0, 999).round()}% of goal', ?stepSensorLabel(d.steps, l), ].join(' · '), - onTap: () => go(c, const MetricDetail('steps')), + onTap: () => go(c, detail('steps')), trailing: d.steps.value == null || d.stepGoal <= 0 ? null : SizedBox( @@ -2224,7 +2230,7 @@ class _HomeScreenState extends State with RevisionReload { ? (l?.homeCaloriesEstimated ?? 'Estimated') : (l?.homeCaloriesTotal(thousands(d.caloriesTotal.value)) ?? '${thousands(d.caloriesTotal.value)} total'), - onTap: () => go(c, const MetricDetail('calories'))), + onTap: () => go(c, detail('calories'))), // No `why:`. It said "Needs your weight and age" — and the measured run // printed that to a profile carrying both, because energy had gone absent // for an entirely different reason that the card never asked for. diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index c7e363946..5ee95d824 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -682,6 +682,22 @@ bool _labelSources(MetricData d) { return false; } +/// Where one day of a metric opens. Each metric lands on the screen that +/// actually shows that day — Nerd stats is the fallback because it is the one +/// screen that exists for every key. +/// +/// Shared with Home: a tile on a PAST day has to open that day, and +/// [MetricDetail] is anchored to today, so it cannot. +Widget metricDayScreen(String key, String day) => switch (key) { + 'sleep' || + 'deep' || + 'rem' || + 'efficiency' => + SleepDetail(day: day), + 'steps' => DayStepsDetail(day: day), + _ => Investigate(key, day: day), + }; + class MetricDetail extends StatefulWidget { final String metricKey; final MetricData? data; @@ -708,8 +724,9 @@ class _MetricDetailState extends State { ]; } - /// TODAY. A tile on Home shows today's number, so the screen behind that tap - /// opens on today's number — anything else is a different question than the + /// TODAY. A tile on Home showing today shows today's number, so the screen + /// behind that tap opens on today's number (a past day's tile opens + /// [metricDayScreen] instead) — anything else is a different question than the /// one that was asked. /// /// It used to open on 30 days, and worse, on the WIDEST range the install had @@ -1651,18 +1668,7 @@ class _MetricDetailState extends State { ); } - /// Where a day opens. Each metric lands on the screen that actually shows - /// that day — Nerd stats is the fallback because it is the one screen that - /// exists for every key. - Widget _dayScreen(String key, String day) => switch (key) { - 'sleep' || - 'deep' || - 'rem' || - 'efficiency' => - SleepDetail(day: day), - 'steps' => DayStepsDetail(day: day), - _ => Investigate(key, day: day), - }; + Widget _dayScreen(String key, String day) => metricDayScreen(key, day); /// [latestTs] is the stamp on the newest STORED point — the day the rank was /// computed for. `metric_series` gets a row only on a day that derives and diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index da3b6a291..acb16a9f8 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -138,6 +138,53 @@ void main() { _yesterday); }); + // The glance tiles show the switcher's day, so the screen behind a tap has + // to be that day too. They used to open the trend screen on today's window: + // a past day's "2,500" steps landed on today's count. + for (final (label, key) in [ + ('2,500', 'steps'), + ('60', 'resting_hr'), + ('300', 'calories'), + ]) { + testWidgets('on a past day the $key tile opens THAT day', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _Repo(); + + await t.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: ThemeController.seed( + AppThemeChoice.light, Brightness.light)), + ], + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + final tile = find.text(label); + await t.scrollUntilVisible(tile, 300, + scrollable: find.byType(Scrollable).first); + await t.tap(tile); + await _settle(t); + + expect(find.byType(MetricDetail), findsNothing); + if (key == 'steps') { + expect(t.widget(find.byType(DayStepsDetail)).day, + _yesterday); + } else { + final inv = t.widget(find.byType(Investigate)); + expect(inv.metricKey, key); + expect(inv.day, _yesterday); + } + }); + } + testWidgets('back on today the sleep ring opens the held-over night', (t) async { final app = AppState.forTesting(); From 13379e82bc516332d1406ce392c82afba0ba63eb Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:29:55 +0530 Subject: [PATCH 11/72] the home day switcher decoded the same stored day three times per arrow tap overview, strain and sleep each fetched the day's row and expanded every stored curve again on the ui isolate. loadForDay now runs the three reads in a scope where the repository decodes each day once and shares the read-only bundle between them. (cherry picked from commit a2ddddb4d8183aa33b847f7206ef0788d8e96314) --- lib/data/local_repository.dart | 6 ++ lib/data/local_repository_impl.dart | 32 ++++++++- lib/ui2/screens/home_screen.dart | 10 ++- test/home_day_switch_decode_once_test.dart | 79 ++++++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 test/home_day_switch_decode_once_test.dart diff --git a/lib/data/local_repository.dart b/lib/data/local_repository.dart index 24453af85..9b93ed30f 100644 --- a/lib/data/local_repository.dart +++ b/lib/data/local_repository.dart @@ -49,6 +49,12 @@ bool rmssdFromSessionEstimator(Object? sessionEnvelope) => /// The local data contract for every insights screen + the AI Coach. /// Return types mirror the cloud ApiClient exactly (defensive Map/List blobs). abstract class LocalRepository { + /// Runs [body] with each stored day it reads fetched and decoded at most + /// once. For a caller that reads several per-day getters for the same day + /// back to back (the Home day switcher), each of which would otherwise + /// decode that day's whole bundle again. The reads inside must not write. + Future sharingDayReads(Future Function() body) => body(); + Future pendingActivityCount() async => (await pendingActivities()).length; Future> pendingActivities() => diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 90d64f398..bf9371c85 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -18,6 +18,8 @@ import 'dart:convert'; import 'dart:isolate'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show visibleForTesting; + import '../compute/derivation_engine.dart'; import '../compute/findings.dart' show servedReadiness; import '../compute/hr_max.dart'; @@ -97,10 +99,38 @@ class LocalRepositoryImpl extends LocalRepository { // ── helpers ──────────────────────────────────────────────────────────────── + static const _dayBundleMemo = #localRepositoryDayBundleMemo; + + /// Bundle decodes performed by [_bundle], for tests. + @visibleForTesting + static int bundleDecodes = 0; + + @override + Future sharingDayReads(Future Function() body) => + Zone.current[_dayBundleMemo] != null + ? body() + : runZoned(body, zoneValues: { + _dayBundleMemo: ?>>{}, + }); + /// Decode a day_result row's payload bundle (latest algo_version), or null. - Future?> _bundle(String date) async { + /// + /// Inside [sharingDayReads] the decoded bundle is shared by every getter for + /// that day: a full decode expands every stored curve into per-sample maps, + /// and the day switcher used to pay for it three times per tap on the UI + /// isolate. Readers only ever read the bundle, which is what makes the + /// sharing safe. + Future?> _bundle(String date) { + final memo = Zone.current[_dayBundleMemo] + as Map?>>?; + if (memo == null) return _loadBundle(date); + return memo[date] ??= _loadBundle(date); + } + + Future?> _loadBundle(String date) async { final row = await LocalDb.dayResult(date); if (row == null) return null; + bundleDecodes++; final b = _decode(row['payload_json']); return b == null ? null : withoutWearableEstimates(b); } diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index c6287dd28..f2d97c89b 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1389,9 +1389,13 @@ class HomeData { static Future loadForDay(LocalRepository repo, String date, [AppLocalizations? l]) async { final profile = await repo.getProfile(); - final overview = await repo.getDayOverview(date); - final strain = await repo.getDayStrain(date); - final sleep = await repo.getDaySleepV2(date); + // The three getters read the same stored day: decode it once, not three + // times per arrow tap. + final (overview, strain, sleep) = await repo.sharingDayReads(() async => ( + await repo.getDayOverview(date), + await repo.getDayStrain(date), + await repo.getDaySleepV2(date), + )); final readiness = metricOf(overview['readiness']); // Same lookup [load] does for today, for the same reason: the ring's // shortfall explanation needs the stored diagnostic, and a day the diff --git a/test/home_day_switch_decode_once_test.dart b/test/home_day_switch_decode_once_test.dart new file mode 100644 index 000000000..1b1d2a9a7 --- /dev/null +++ b/test/home_day_switch_decode_once_test.dart @@ -0,0 +1,79 @@ +// The Home day switcher reads three per-day getters (overview, strain, sleep) +// for the day it stepped onto. Each used to fetch and fully decode that day's +// stored bundle on its own, so every arrow tap decoded the same row three +// times on the UI isolate. + +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/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/ui2/screens/home_screen.dart' show HomeData; + +void main() { + late LocalRepositoryImpl repo; + const day = '2026-03-10'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_home_day_decode_once_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + repo = LocalRepositoryImpl(getProfileMap: () => const {}); + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode({ + 'date': day, + 'scalars': {'rhr': 51.0, 'strain': 9.4, 'steps': 7200}, + 'sleep': { + 'accounting': { + 'value': {'tst_sec': 420 * 60}, + }, + }, + 'series': { + 'strain_curve': [ + for (var i = 0; i < 50; i++) {'t': 1000 + i * 60, 'v': i / 10}, + ], + }, + }), + windowJson: '{}', + ); + }); + + tearDownAll(() async { + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + test('stepping onto a past day decodes its bundle once', () async { + final before = LocalRepositoryImpl.bundleDecodes; + final h = await HomeData.loadForDay(repo, day); + expect(LocalRepositoryImpl.bundleDecodes - before, 1); + + // Same figures the unshared getters serve. + final strain = await repo.getDayStrain(day); + final overview = await repo.getDayOverview(day); + final sleep = await repo.getDaySleepV2(day); + expect(h.strain.value, strain['strain']); + expect(h.steps.value, strain['steps']); + expect(h.rhr.value, overview['resting_hr']); + expect(h.sleepMin.value, sleep['duration_min']); + expect(h.sleepMin.value, isNotNull); + }); + + test('the shared decode does not outlive its scope', () async { + await repo.sharingDayReads(() => repo.getDayStrain(day)); + final before = LocalRepositoryImpl.bundleDecodes; + await repo.getDayStrain(day); + await repo.getDayStrain(day); + expect(LocalRepositoryImpl.bundleDecodes - before, 2); + }); +} From 59890143939251ccfc17de70af18c27dc2bca127 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:30:35 +0530 Subject: [PATCH 12/72] journal ai send button had no screen-reader name and 'add to journal' was always english the arrow-up send control in the journal ai sheet is icon-only and passed no label, so voiceover/talkback announced it as just "button". it now uses the existing send label. the sheet's primary 'add to journal' button was a raw literal with no arb key, so it stayed english in every locale; it now has its own key with translations for every shipped locale. (cherry picked from commit ff0876f65a9db3c6ee5184b0bcbad942835c0b1c) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/journal_compose.dart | 5 ++- test/journal_ai_sheet_a11y_test.dart | 67 ++++++++++++++++++++++++++++ 9 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/journal_ai_sheet_a11y_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e79801b12..1d01e43ec 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -772,6 +772,7 @@ "journalComposeLoadFailed": "Das Journal für diesen Tag konnte nicht geöffnet werden.", "journalComposeTryAgain": "Erneut versuchen", "journalComposeWhenWasLastOne": "Wann war das letzte Mal?", + "journalComposeAddToJournal": "Ins Journal übernehmen", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Heute", "journalComposeTrackSomethingElse": "Etwas anderes erfassen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e2c249111..40dcd6a82 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3696,6 +3696,10 @@ "@journalComposeWhenWasLastOne": { "description": "Time picker help text when logging the time of the last dose of a journal field." }, + "journalComposeAddToJournal": "Add to journal", + "@journalComposeAddToJournal": { + "description": "Journal AI chat sheet: primary button that copies the proposed tags and note into the day's entry." + }, "journalComposeTitle": "Journal", "@journalComposeTitle": { "description": "Journal compose screen title." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 107b8dc21..8d3e2ff98 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -743,6 +743,7 @@ "journalComposeLoadFailed": "No se pudo abrir el diario de este día.", "journalComposeTryAgain": "Reintentar", "journalComposeWhenWasLastOne": "¿Cuándo fue la última vez?", + "journalComposeAddToJournal": "Añadir al diario", "journalComposeTitle": "Diario", "journalComposeTodaySection": "Hoy", "journalComposeTrackSomethingElse": "Registrar algo más", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index af67f9b3e..cb8c71304 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -743,6 +743,7 @@ "journalComposeLoadFailed": "Impossible d'ouvrir le journal de ce jour.", "journalComposeTryAgain": "Réessayer", "journalComposeWhenWasLastOne": "Quand était la dernière fois ?", + "journalComposeAddToJournal": "Ajouter au journal", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Aujourd'hui", "journalComposeTrackSomethingElse": "Suivre autre chose", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7a2e061fc..4832925af 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -743,6 +743,7 @@ "journalComposeLoadFailed": "इस दिन का जर्नल नहीं खुल सका।", "journalComposeTryAgain": "फिर से कोशिश करें", "journalComposeWhenWasLastOne": "पिछली बार कब हुआ था?", + "journalComposeAddToJournal": "जर्नल में जोड़ें", "journalComposeTitle": "जर्नल", "journalComposeTodaySection": "आज", "journalComposeTrackSomethingElse": "कुछ और ट्रैक करें", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 03601ae2a..a2c8406be 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1590,6 +1590,7 @@ "journalComposeLoadFailed": "Не удалось открыть дневник за этот день.", "journalComposeTryAgain": "Повторить", "journalComposeWhenWasLastOne": "Когда это было в последний раз?", + "journalComposeAddToJournal": "Добавить в дневник", "journalComposeTitle": "Дневник", "journalComposeTodaySection": "Сегодня", "journalComposeTrackSomethingElse": "Добавить свой показатель", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 90a2d56d4..3f9b1978b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -743,6 +743,7 @@ "journalComposeLoadFailed": "无法打开这一天的日志。", "journalComposeTryAgain": "重试", "journalComposeWhenWasLastOne": "上一次是什么时候?", + "journalComposeAddToJournal": "添加到日志", "journalComposeTitle": "日志", "journalComposeTodaySection": "今天", "journalComposeTrackSomethingElse": "记录其他内容", diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 00fc0ed09..d5dde3e21 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -1250,6 +1250,8 @@ class _JournalAiSheetState extends State<_JournalAiSheet> { const SizedBox(width: S.x2), Pressable( onTap: _busy ? null : () => unawaited(_send()), + semanticLabel: + AppLocalizations.of(c)?.coachSendLabel ?? 'Send', child: Container( width: 44, height: 44, @@ -1274,7 +1276,8 @@ class _JournalAiSheetState extends State<_JournalAiSheet> { Padding( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x4), child: BigButton( - 'Add to journal', + AppLocalizations.of(c)?.journalComposeAddToJournal ?? + 'Add to journal', icon: LucideIcons.check, color: C.domMind, onTap: last == null diff --git a/test/journal_ai_sheet_a11y_test.dart b/test/journal_ai_sheet_a11y_test.dart new file mode 100644 index 000000000..cbf15ea7b --- /dev/null +++ b/test/journal_ai_sheet_a11y_test.dart @@ -0,0 +1,67 @@ +// The journal AI sheet's send control is an arrow icon with no text, so +// without a label a screen reader announced it as a bare "button". Its +// primary "Add to journal" button was a raw English literal, so it stayed +// English under a translated chat. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/coach/coach_config.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +class _ReadyCoach extends CoachConfig { + @override + bool get configured => true; +} + +Future _openSheet(WidgetTester t, Locale locale) async { + // Wide enough that the test font's square glyphs don't overflow the + // compose screen's Russian row labels, which is not what this checks. + t.view.physicalSize = const Size(900 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: AppState.forTesting()), + ChangeNotifierProvider.value(value: _ReadyCoach()), + ], + child: const Scaffold(body: JournalCompose(date: '2026-09-01')), + ), + )); + await t.pumpAndSettle(); + await t.tap(find.text('Talk it through with AI')); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('send control has a screen-reader name', (t) async { + final handle = t.ensureSemantics(); + await _openSheet(t, const Locale('en')); + expect( + find.bySemanticsLabel('Send'), + findsOneWidget, + ); + handle.dispose(); + }); + + testWidgets('send label and Add to journal follow the locale', (t) async { + final handle = t.ensureSemantics(); + await _openSheet(t, const Locale('ru')); + expect(find.bySemanticsLabel('Отправить'), findsOneWidget); + expect(find.text('Add to journal'), findsNothing); + expect(find.text('Добавить в дневник'), findsOneWidget); + handle.dispose(); + }); +} From ab1cdb5c491cd2cb8622eab91526131ea59ce3a1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:34:56 +0530 Subject: [PATCH 13/72] height and weight could not take a decimal on iphone the profile and onboarding fields used a plain number keyboard, which on ios is the digits-only number pad. a weight pre-filled as 72.5 could not be retyped as 73.4 once cleared; it went in as 734 or 73. both forms now ask for a decimal keyboard for height and weight. age stays a whole number. (cherry picked from commit 70763512c12a3d028cc538c8d9744d0499d71b8e) --- lib/ui2/onboarding/profile_setup.dart | 13 ++++- lib/ui2/profile/settings.dart | 6 ++- test/profile_decimal_keyboard_test.dart | 66 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 test/profile_decimal_keyboard_test.dart diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 27a4668e6..daa4d29e9 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -221,6 +221,7 @@ class _ProfileSetupViewState extends State { uiText(c, _u.isImperial ? 'in' : 'cm'), l?.profileSetupHeightConsequence ?? 'Without it: stride length, and distance from steps.', + decimal: true, ), _Field( _weight, @@ -228,6 +229,7 @@ class _ProfileSetupViewState extends State { uiText(c, _u.isImperial ? 'lb' : 'kg'), l?.profileSetupWeightConsequence ?? 'Without it: calories and training load.', + decimal: true, ), // Offered to everyone, on by default for a female profile. // Changeable any time in Settings; switching it off keeps what @@ -293,7 +295,12 @@ class _Choice extends StatelessWidget { class _Field extends StatelessWidget { final TextEditingController controller; final String label, unit, consequence; - const _Field(this.controller, this.label, this.unit, this.consequence); + + /// Height and weight take a decimal; age does not. A plain number keyboard + /// on iOS has no separator key at all, so 72.5 kg could not be typed. + final bool decimal; + const _Field(this.controller, this.label, this.unit, this.consequence, + {this.decimal = false}); @override Widget build(BuildContext c) { @@ -309,7 +316,9 @@ class _Field extends StatelessWidget { const SizedBox(height: S.x1), TextField( controller: controller, - keyboardType: TextInputType.number, + keyboardType: decimal + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, style: F.head.copyWith(color: p.ink), decoration: InputDecoration( hintText: unit, diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index c06f0ebd3..db0d71b15 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -1737,7 +1737,9 @@ class _EditProfileViewState extends State { l?.localeName.startsWith('ru') == true ? uiText(c, _u.heightLabel) : uiText(c, _u.heightLabel).toUpperCase(), - TextInputType.number, + // A plain number keyboard on iOS is digits only, so a weight + // pre-filled as 72.5 could not be retyped as 73.4. + const TextInputType.numberWithOptions(decimal: true), ), const SizedBox(height: S.x4), _text( @@ -1746,7 +1748,7 @@ class _EditProfileViewState extends State { l?.localeName.startsWith('ru') == true ? uiText(c, _u.weightLabel) : uiText(c, _u.weightLabel).toUpperCase(), - TextInputType.number, + const TextInputType.numberWithOptions(decimal: true), ), ..._importBlock(p, c), const SizedBox(height: S.x6), diff --git a/test/profile_decimal_keyboard_test.dart b/test/profile_decimal_keyboard_test.dart new file mode 100644 index 000000000..b68848ed4 --- /dev/null +++ b/test/profile_decimal_keyboard_test.dart @@ -0,0 +1,66 @@ +// Height and weight are decimals (a metric weight pre-fills as "72.5"), but a +// plain `TextInputType.number` is iOS's digits-only number pad: clearing the +// field to correct it left no way to type the separator, so 73.4 went in as +// 734 or 73. Age stays a whole number. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _decimal = TextInputType.numberWithOptions(decimal: true); + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: child, + )); + await t.pumpAndSettle(); +} + +TextInputType? _keyboardOf(WidgetTester t, String text) => + t.widget(find.widgetWithText(TextField, text)).keyboardType; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('edit profile: height and weight offer a decimal key', + (t) async { + await _pump( + t, + EditProfileView( + initial: const { + 'sex': 'm', + 'age': 34, + 'height_cm': 178.5, + 'weight_kg': 72.5, + }, + onSave: (_) async {}, + )); + expect(_keyboardOf(t, '178.5'), _decimal); + expect(_keyboardOf(t, '72.5'), _decimal); + expect(_keyboardOf(t, '34'), TextInputType.number); + }); + + testWidgets('onboarding: height and weight offer a decimal key', (t) async { + await _pump( + t, + ProfileSetupView( + initial: const { + 'sex': 'f', + 'age': 34, + 'height_cm': 165.5, + 'weight_kg': 61.5, + }, + onSave: (_) async {}, + )); + expect(_keyboardOf(t, '165.5'), _decimal); + expect(_keyboardOf(t, '61.5'), _decimal); + expect(_keyboardOf(t, '34'), TextInputType.number); + }); +} From d325d60c02a4327d01cc201ed2da687e7e555812 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:38:22 +0530 Subject: [PATCH 14/72] the live heart rate card stayed in english on a translated screen the card's empty states (no band paired, band not connected, no recent beat) and the caption under the live trace were raw english literals. StatusCard only translates strings its lookup tables already know, so these came back unchanged in every locale while the pill and bpm unit next to them were translated. they now come from AppLocalizations, with strings for every shipped language. (cherry picked from commit 1a94d335901df3a71a69731751ede0882ed3a5c4) --- lib/l10n/app_de.arb | 16 +++++-- lib/l10n/app_en.arb | 60 +++++++++++++++++++------- lib/l10n/app_es.arb | 16 +++++-- lib/l10n/app_fr.arb | 16 +++++-- lib/l10n/app_hi.arb | 16 +++++-- lib/l10n/app_ru.arb | 36 ++++++++++++++-- lib/l10n/app_zh.arb | 16 +++++-- lib/ui2/live_hr.dart | 44 +++++++++++++------ test/live_hr_card_l10n_test.dart | 74 ++++++++++++++++++++++++++++++++ 9 files changed, 240 insertions(+), 54 deletions(-) create mode 100644 test/live_hr_card_l10n_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1d01e43ec..e8c4f5911 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -768,11 +768,7 @@ "dayTimelinePatternsNote": "Muster in deinen eigenen Daten, keine Ursachen. Stehen hier zwei Dinge nebeneinander, sind sie zeitlich nah beieinander passiert – mehr behauptet diese Seite nicht.", "journalComposeNotReady": "Noch nicht bereit – öffne zuerst die App.", "journalComposeSaveFailed": "Konnte nicht gespeichert werden – prüfe den Speicherplatz und versuch es erneut.", - "journalComposeEntrySaveFailed": "Nicht gespeichert – dein Eintrag ist noch da. Versuch es erneut.", - "journalComposeLoadFailed": "Das Journal für diesen Tag konnte nicht geöffnet werden.", - "journalComposeTryAgain": "Erneut versuchen", "journalComposeWhenWasLastOne": "Wann war das letzte Mal?", - "journalComposeAddToJournal": "Ins Journal übernehmen", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Heute", "journalComposeTrackSomethingElse": "Etwas anderes erfassen", @@ -2798,8 +2794,20 @@ "dataReanalyzeFailed": "Neuanalyse fehlgeschlagen: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 Tag konnte nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.} other{{n} Tage konnten nicht analysiert werden. Wenn das wiederholt passiert, melde es bitte.}}", "napsInFuture": "Dieses Nickerchen ist noch nicht vorbei. Trage es ein, wenn du wach bist.", + "journalComposeEntrySaveFailed": "Nicht gespeichert – dein Eintrag ist noch da. Versuch es erneut.", + "journalComposeLoadFailed": "Das Journal für diesen Tag konnte nicht geöffnet werden.", + "journalComposeTryAgain": "Erneut versuchen", + "journalComposeAddToJournal": "Ins Journal übernehmen", "workoutLoadFailedTitle": "Aktivität konnte nicht gelesen werden", "workoutLoadFailedBody": "Deine Einheiten konnten nicht geladen werden. Nichts wurde gelöscht – das ist ein fehlgeschlagener Lesevorgang, keine fehlenden Daten.", + "liveHrNoReadingTitle": "Kein Live-Wert", + "liveHrNotPairedWhy": "Es ist kein Band gekoppelt.", + "liveHrNotPairedFix": "Kopple eines im Profil, um Live-Herzschläge zu lesen.", + "liveHrNotConnectedWhy": "Dein Band ist nicht verbunden.", + "liveHrNotConnectedFix": "Live-Herzschläge brauchen eine offene Verbindung — die App verbindet sich, wenn du sie mit dem Band in Reichweite öffnest.", + "liveHrNoRecentBeatWhy": "Kein Herzschlag in den letzten {seconds} Sekunden.", + "liveHrStreamingFix": "Das Band sendet, solange es am Handgelenk ist und die App geöffnet ist.", + "liveHrTraceCaption": "Die letzten {count} Werte — {min}–{max} bpm. Nicht gespeichert; das ist der Live-Stream, keine Aufzeichnung deines Tages.", "notifRecoveryReadyBody": "Erholung {score}.", "notifRecoveryReadySleptBody": "Erholung {score}, geschlafen {hours} h {minutes} min.", "notifStepGoalBody": "Du hast etwa {steps} Schritte erreicht – mindestens dein Ziel von {goal}.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 40dcd6a82..5ede113c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3680,26 +3680,10 @@ "@journalComposeSaveFailed": { "description": "Snackbar shown when saving a new custom journal field fails." }, - "journalComposeEntrySaveFailed": "Not saved — your entry is still here. Try again.", - "@journalComposeEntrySaveFailed": { - "description": "Snackbar shown when saving the day's journal entry fails; the screen stays open with the entry intact." - }, - "journalComposeLoadFailed": "Could not open this day's journal.", - "@journalComposeLoadFailed": { - "description": "Shown in place of the journal form when reading the day's entry fails." - }, - "journalComposeTryAgain": "Try again", - "@journalComposeTryAgain": { - "description": "Button that retries loading the journal entry after a failure." - }, "journalComposeWhenWasLastOne": "When was the last one?", "@journalComposeWhenWasLastOne": { "description": "Time picker help text when logging the time of the last dose of a journal field." }, - "journalComposeAddToJournal": "Add to journal", - "@journalComposeAddToJournal": { - "description": "Journal AI chat sheet: primary button that copies the proposed tags and note into the day's entry." - }, "journalComposeTitle": "Journal", "@journalComposeTitle": { "description": "Journal compose screen title." @@ -15888,6 +15872,22 @@ "@napsInFuture": { "description": "Validation error when a manually logged nap ends after the current time." }, + "journalComposeEntrySaveFailed": "Not saved — your entry is still here. Try again.", + "@journalComposeEntrySaveFailed": { + "description": "Snackbar shown when saving the day's journal entry fails; the screen stays open with the entry intact." + }, + "journalComposeLoadFailed": "Could not open this day's journal.", + "@journalComposeLoadFailed": { + "description": "Shown in place of the journal form when reading the day's entry fails." + }, + "journalComposeTryAgain": "Try again", + "@journalComposeTryAgain": { + "description": "Button that retries loading the journal entry after a failure." + }, + "journalComposeAddToJournal": "Add to journal", + "@journalComposeAddToJournal": { + "description": "Journal AI chat sheet: primary button that copies the proposed tags and note into the day's entry." + }, "workoutLoadFailedTitle": "Activity could not be read", "@workoutLoadFailedTitle": { "description": "Title of the card shown on the Activity tab when the sessions failed to load from the database." @@ -15896,6 +15896,34 @@ "@workoutLoadFailedBody": { "description": "Body of the card shown on the Activity tab when the sessions failed to load from the database." }, + "liveHrNoReadingTitle": "No live reading", + "liveHrNotPairedWhy": "No band is paired.", + "liveHrNotPairedFix": "Pair one from Profile to read live beats.", + "liveHrNotConnectedWhy": "Your band is not connected.", + "liveHrNotConnectedFix": "Live beats need an open link — the app connects when you open it with the band in range.", + "liveHrNoRecentBeatWhy": "No beat in the last {seconds} seconds.", + "@liveHrNoRecentBeatWhy": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "liveHrStreamingFix": "The band streams while it is on your wrist and the app is open.", + "liveHrTraceCaption": "The last {count} readings — {min}–{max} bpm. Not stored; this is the live stream, not a record of your day.", + "@liveHrTraceCaption": { + "placeholders": { + "count": { + "type": "int" + }, + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "notifRecoveryReadyBody": "Recovery {score}.", "@notifRecoveryReadyBody": { "description": "OS notification body when the day's recovery score is ready.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8d3e2ff98..b1a319277 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -739,11 +739,7 @@ "dayTimelinePatternsNote": "Patrones en tus propios registros, no causas. Que dos cosas aparezcan cerca aquí solo significa que ocurrieron cerca en el tiempo.", "journalComposeNotReady": "Aún no está listo — abre la app primero.", "journalComposeSaveFailed": "No se pudo guardar — comprueba el almacenamiento e inténtalo de nuevo.", - "journalComposeEntrySaveFailed": "No se guardó — tu entrada sigue aquí. Inténtalo de nuevo.", - "journalComposeLoadFailed": "No se pudo abrir el diario de este día.", - "journalComposeTryAgain": "Reintentar", "journalComposeWhenWasLastOne": "¿Cuándo fue la última vez?", - "journalComposeAddToJournal": "Añadir al diario", "journalComposeTitle": "Diario", "journalComposeTodaySection": "Hoy", "journalComposeTrackSomethingElse": "Registrar algo más", @@ -2702,8 +2698,20 @@ "dataReanalyzeFailed": "El reanálisis falló: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{No se pudo analizar 1 día. Si vuelve a pasar, por favor infórmalo.} other{No se pudieron analizar {n} días. Si vuelve a pasar, por favor infórmalo.}}", "napsInFuture": "Esa siesta todavía no ha terminado. Regístrala cuando te despiertes.", + "journalComposeEntrySaveFailed": "No se guardó — tu entrada sigue aquí. Inténtalo de nuevo.", + "journalComposeLoadFailed": "No se pudo abrir el diario de este día.", + "journalComposeTryAgain": "Reintentar", + "journalComposeAddToJournal": "Añadir al diario", "workoutLoadFailedTitle": "No se pudo leer la actividad", "workoutLoadFailedBody": "Tus sesiones no se pudieron cargar. No se ha borrado nada: es una lectura que falló, no datos que falten.", + "liveHrNoReadingTitle": "Sin lectura en directo", + "liveHrNotPairedWhy": "No hay ninguna banda emparejada.", + "liveHrNotPairedFix": "Empareja una desde Perfil para leer latidos en directo.", + "liveHrNotConnectedWhy": "Tu banda no está conectada.", + "liveHrNotConnectedFix": "Los latidos en directo necesitan una conexión abierta: la app se conecta al abrirla con la banda cerca.", + "liveHrNoRecentBeatWhy": "Ningún latido en los últimos {seconds} segundos.", + "liveHrStreamingFix": "La banda transmite mientras está en tu muñeca y la app está abierta.", + "liveHrTraceCaption": "Las últimas {count} lecturas: {min}–{max} lpm. No se guardan; es la transmisión en directo, no un registro de tu día.", "notifRecoveryReadyBody": "Recuperación {score}.", "notifRecoveryReadySleptBody": "Recuperación {score}, dormiste {hours} h {minutes} min.", "notifStepGoalBody": "Llegaste a unos {steps} pasos, al menos tu objetivo de {goal}.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cb8c71304..5943dcfb8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -739,11 +739,7 @@ "dayTimelinePatternsNote": "Des motifs dans vos propres données, pas des causes. Deux éléments proches ici se sont simplement produits près l'un de l'autre dans le temps, c'est tout ce que cette page affirme.", "journalComposeNotReady": "Pas encore prêt — ouvrez d'abord l'application.", "journalComposeSaveFailed": "Impossible d'enregistrer — vérifiez le stockage et réessayez.", - "journalComposeEntrySaveFailed": "Non enregistré — votre saisie est toujours là. Réessayez.", - "journalComposeLoadFailed": "Impossible d'ouvrir le journal de ce jour.", - "journalComposeTryAgain": "Réessayer", "journalComposeWhenWasLastOne": "Quand était la dernière fois ?", - "journalComposeAddToJournal": "Ajouter au journal", "journalComposeTitle": "Journal", "journalComposeTodaySection": "Aujourd'hui", "journalComposeTrackSomethingElse": "Suivre autre chose", @@ -2673,8 +2669,20 @@ "dataReanalyzeFailed": "La réanalyse a échoué : {error}", "dataReanalyzeDaysFailed": "{n, plural, one{{n} jour n’a pas pu être analysé. Si cela se reproduit, merci de le signaler.} other{{n} jours n’ont pas pu être analysés. Si cela se reproduit, merci de le signaler.}}", "napsInFuture": "Cette sieste n'est pas encore terminée. Enregistrez-la une fois réveillé.", + "journalComposeEntrySaveFailed": "Non enregistré — votre saisie est toujours là. Réessayez.", + "journalComposeLoadFailed": "Impossible d'ouvrir le journal de ce jour.", + "journalComposeTryAgain": "Réessayer", + "journalComposeAddToJournal": "Ajouter au journal", "workoutLoadFailedTitle": "Impossible de lire l'activité", "workoutLoadFailedBody": "Vos séances n'ont pas pu être chargées. Rien n'a été supprimé : c'est une lecture qui a échoué, pas des données manquantes.", + "liveHrNoReadingTitle": "Aucune mesure en direct", + "liveHrNotPairedWhy": "Aucun bracelet n'est appairé.", + "liveHrNotPairedFix": "Appairez-en un depuis le Profil pour lire les battements en direct.", + "liveHrNotConnectedWhy": "Votre bracelet n'est pas connecté.", + "liveHrNotConnectedFix": "Les battements en direct exigent une liaison ouverte : l'app se connecte quand vous l'ouvrez avec le bracelet à portée.", + "liveHrNoRecentBeatWhy": "Aucun battement dans les {seconds} dernières secondes.", + "liveHrStreamingFix": "Le bracelet transmet tant qu'il est à votre poignet et que l'app est ouverte.", + "liveHrTraceCaption": "Les {count} dernières mesures : {min}–{max} bpm. Non enregistrées ; c'est le flux en direct, pas un relevé de votre journée.", "notifRecoveryReadyBody": "Récupération {score}.", "notifRecoveryReadySleptBody": "Récupération {score}, sommeil {hours} h {minutes} min.", "notifStepGoalBody": "Vous avez fait environ {steps} pas, au moins votre objectif de {goal}.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4832925af..1030478a4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -739,11 +739,7 @@ "dayTimelinePatternsNote": "यह आपके अपने रिकॉर्ड में पैटर्न है, कारण नहीं। यहाँ पास-पास दिखने वाली दो चीज़ों का मतलब सिर्फ़ इतना है कि वे समय में करीब हुईं — यह पेज बस इतना ही बताता है।", "journalComposeNotReady": "अभी तैयार नहीं है — पहले ऐप खोलें।", "journalComposeSaveFailed": "सेव नहीं हो सका — स्टोरेज जाँचें और फिर से कोशिश करें।", - "journalComposeEntrySaveFailed": "सेव नहीं हुआ — आपकी एंट्री अभी भी यहीं है। फिर से कोशिश करें।", - "journalComposeLoadFailed": "इस दिन का जर्नल नहीं खुल सका।", - "journalComposeTryAgain": "फिर से कोशिश करें", "journalComposeWhenWasLastOne": "पिछली बार कब हुआ था?", - "journalComposeAddToJournal": "जर्नल में जोड़ें", "journalComposeTitle": "जर्नल", "journalComposeTodaySection": "आज", "journalComposeTrackSomethingElse": "कुछ और ट्रैक करें", @@ -2673,8 +2669,20 @@ "dataReanalyzeFailed": "फिर से विश्लेषण विफल: {error}", "dataReanalyzeDaysFailed": "{n, plural, one{1 दिन का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।} other{{n} दिनों का विश्लेषण नहीं हो सका। अगर यह बार-बार हो, तो कृपया इसकी रिपोर्ट करें।}}", "napsInFuture": "यह झपकी अभी खत्म नहीं हुई है। जागने के बाद इसे दर्ज करें।", + "journalComposeEntrySaveFailed": "सेव नहीं हुआ — आपकी एंट्री अभी भी यहीं है। फिर से कोशिश करें।", + "journalComposeLoadFailed": "इस दिन का जर्नल नहीं खुल सका।", + "journalComposeTryAgain": "फिर से कोशिश करें", + "journalComposeAddToJournal": "जर्नल में जोड़ें", "workoutLoadFailedTitle": "गतिविधि पढ़ी नहीं जा सकी", "workoutLoadFailedBody": "आपके सत्र लोड नहीं हो सके। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", + "liveHrNoReadingTitle": "कोई लाइव रीडिंग नहीं", + "liveHrNotPairedWhy": "कोई बैंड पेयर नहीं है।", + "liveHrNotPairedFix": "लाइव धड़कनें पढ़ने के लिए प्रोफ़ाइल से एक बैंड पेयर करें।", + "liveHrNotConnectedWhy": "आपका बैंड कनेक्ट नहीं है।", + "liveHrNotConnectedFix": "लाइव धड़कनों के लिए खुला लिंक चाहिए — बैंड पास होने पर ऐप खोलते ही वह कनेक्ट हो जाता है।", + "liveHrNoRecentBeatWhy": "पिछले {seconds} सेकंड में कोई धड़कन नहीं।", + "liveHrStreamingFix": "बैंड तब तक स्ट्रीम करता है जब तक वह आपकी कलाई पर है और ऐप खुला है।", + "liveHrTraceCaption": "पिछली {count} रीडिंग — {min}–{max} bpm. सहेजी नहीं जातीं; यह लाइव स्ट्रीम है, आपके दिन का रिकॉर्ड नहीं।", "notifRecoveryReadyBody": "रिकवरी {score}.", "notifRecoveryReadySleptBody": "रिकवरी {score}, नींद {hours} घं {minutes} मि.", "notifStepGoalBody": "आपने लगभग {steps} कदम पूरे किए — आपके {goal} के लक्ष्य जितने या उससे ज़्यादा।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a2c8406be..7822433fb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1586,11 +1586,7 @@ "dayTimelinePatternsNote": "Это совпадения в ваших записях, а не причины. Соседние события лишь произошли примерно в одно время — большего эта страница не утверждает.", "journalComposeNotReady": "Ещё не готово — сначала откройте приложение.", "journalComposeSaveFailed": "Не удалось сохранить. Проверьте свободное место и повторите.", - "journalComposeEntrySaveFailed": "Не сохранено — запись осталась на экране. Повторите попытку.", - "journalComposeLoadFailed": "Не удалось открыть дневник за этот день.", - "journalComposeTryAgain": "Повторить", "journalComposeWhenWasLastOne": "Когда это было в последний раз?", - "journalComposeAddToJournal": "Добавить в дневник", "journalComposeTitle": "Дневник", "journalComposeTodaySection": "Сегодня", "journalComposeTrackSomethingElse": "Добавить свой показатель", @@ -7565,8 +7561,40 @@ } }, "napsInFuture": "Этот дневной сон ещё не закончился. Запишите его, когда проснётесь.", + "journalComposeEntrySaveFailed": "Не сохранено — запись осталась на экране. Повторите попытку.", + "journalComposeLoadFailed": "Не удалось открыть дневник за этот день.", + "journalComposeTryAgain": "Повторить", + "journalComposeAddToJournal": "Добавить в дневник", "workoutLoadFailedTitle": "Не удалось прочитать активность", "workoutLoadFailedBody": "Не удалось загрузить ваши тренировки. Ничего не удалено — это ошибка чтения, а не отсутствие данных.", + "liveHrNoReadingTitle": "Нет живых данных", + "liveHrNotPairedWhy": "Браслет не подключён к приложению.", + "liveHrNotPairedFix": "Подключите его в Профиле, чтобы видеть пульс в реальном времени.", + "liveHrNotConnectedWhy": "Браслет не на связи.", + "liveHrNotConnectedFix": "Для пульса в реальном времени нужно открытое соединение — приложение подключается, когда вы открываете его рядом с браслетом.", + "liveHrNoRecentBeatWhy": "Нет ударов за последние {seconds} с.", + "@liveHrNoRecentBeatWhy": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "liveHrStreamingFix": "Браслет передаёт данные, пока он на запястье и приложение открыто.", + "liveHrTraceCaption": "Последние значения ({count}) — {min}–{max} уд/мин. Не сохраняются: это поток в реальном времени, а не запись вашего дня.", + "@liveHrTraceCaption": { + "placeholders": { + "count": { + "type": "int" + }, + "min": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, "notifRecoveryReadyBody": "Восстановление {score}.", "@notifRecoveryReadyBody": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3f9b1978b..9fa3651f8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -739,11 +739,7 @@ "dayTimelinePatternsNote": "这是你自己记录中的规律,不是因果关系。这里相邻出现的两件事只是时间上接近,本页面仅说明这一点。", "journalComposeNotReady": "尚未就绪 —— 请先打开应用。", "journalComposeSaveFailed": "保存失败 —— 请检查存储空间后重试。", - "journalComposeEntrySaveFailed": "未保存 —— 你的记录仍在此处,请重试。", - "journalComposeLoadFailed": "无法打开这一天的日志。", - "journalComposeTryAgain": "重试", "journalComposeWhenWasLastOne": "上一次是什么时候?", - "journalComposeAddToJournal": "添加到日志", "journalComposeTitle": "日志", "journalComposeTodaySection": "今天", "journalComposeTrackSomethingElse": "记录其他内容", @@ -2673,8 +2669,20 @@ "dataReanalyzeFailed": "重新分析失败:{error}", "dataReanalyzeDaysFailed": "{n, plural, other{有 {n} 天无法分析。如果反复出现,请报告此问题。}}", "napsInFuture": "这次小睡还没有结束。醒来后再记录。", + "journalComposeEntrySaveFailed": "未保存 —— 你的记录仍在此处,请重试。", + "journalComposeLoadFailed": "无法打开这一天的日志。", + "journalComposeTryAgain": "重试", + "journalComposeAddToJournal": "添加到日志", "workoutLoadFailedTitle": "无法读取活动", "workoutLoadFailedBody": "你的训练记录加载失败。没有删除任何内容——这是读取出错,不是数据缺失。", + "liveHrNoReadingTitle": "无实时读数", + "liveHrNotPairedWhy": "尚未配对手环。", + "liveHrNotPairedFix": "在“个人资料”中配对手环以读取实时心跳。", + "liveHrNotConnectedWhy": "手环未连接。", + "liveHrNotConnectedFix": "实时心跳需要保持连接——手环在附近时打开应用即可自动连接。", + "liveHrNoRecentBeatWhy": "过去 {seconds} 秒内没有检测到心跳。", + "liveHrStreamingFix": "手环戴在手腕上且应用处于打开状态时会持续传输数据。", + "liveHrTraceCaption": "最近 {count} 个读数 — {min}–{max} bpm。不会保存;这是实时数据流,不是你一天的记录。", "notifRecoveryReadyBody": "恢复 {score}。", "notifRecoveryReadySleptBody": "恢复 {score},睡眠 {hours} 小时 {minutes} 分钟。", "notifStepGoalBody": "你走了约 {steps} 步,达到或超过 {goal} 步的目标。", diff --git a/lib/ui2/live_hr.dart b/lib/ui2/live_hr.dart index ef294944a..5939d0e21 100644 --- a/lib/ui2/live_hr.dart +++ b/lib/ui2/live_hr.dart @@ -83,12 +83,13 @@ class LiveHrCard extends StatelessWidget { final l = AppLocalizations.of(c); final hr = _preview ? _hr : c.select((a) => a.liveHr); if (hr == null) { - if (_preview) return _absent(paired: true, connected: true); + if (_preview) return _absent(l, paired: true, connected: true); // SELECTED, not read: with no reading the only thing this widget watched // was `liveHr`, which stays null through both pairing and connecting — so // the card went on saying "No band is paired" after the band was paired // and connected. These are what change in that state. return _absent( + l, paired: c.select((a) => a.isPaired), connected: c.select((a) => a.isConnected), ); @@ -176,9 +177,14 @@ class LiveHrCard extends StatelessWidget { ), const SizedBox(height: S.x2), Text( - 'The last ${trace.length} readings — ${trace.reduce(math.min)}' - '–${trace.reduce(math.max)} bpm. Not stored; this is ' - 'the live stream, not a record of your day.', + l?.liveHrTraceCaption( + trace.length, + trace.reduce(math.min), + trace.reduce(math.max), + ) ?? + 'The last ${trace.length} readings — ${trace.reduce(math.min)}' + '–${trace.reduce(math.max)} bpm. Not stored; this is ' + 'the live stream, not a record of your day.', style: F.over.copyWith(color: p.ink3), ), ], @@ -188,22 +194,32 @@ class LiveHrCard extends StatelessWidget { /// No live reading. Three different facts, and only the one the app can /// actually see is stated. - Widget _absent({required bool paired, required bool connected}) { + // StatusCard's uiText/presentationText only translate strings they already + // know, so these go through AppLocalizations here or a translated screen + // shows this card in English. + Widget _absent(AppLocalizations? l, + {required bool paired, required bool connected}) { + final seconds = AppState.liveHrMaxAge.inSeconds; final (String why, String fix) = !paired - ? ('No band is paired.', 'Pair one from Profile to read live beats.') + ? ( + l?.liveHrNotPairedWhy ?? 'No band is paired.', + l?.liveHrNotPairedFix ?? 'Pair one from Profile to read live beats.' + ) : !connected ? ( - 'Your band is not connected.', - 'Live beats need an open link — the app connects when you open ' - 'it with the band in range.' + l?.liveHrNotConnectedWhy ?? 'Your band is not connected.', + l?.liveHrNotConnectedFix ?? + 'Live beats need an open link — the app connects when you ' + 'open it with the band in range.' ) : ( - 'No beat in the last ${AppState.liveHrMaxAge.inSeconds} ' - 'seconds.', - 'The band streams while it is on your wrist and the app is ' - 'open.' + l?.liveHrNoRecentBeatWhy(seconds) ?? + 'No beat in the last $seconds seconds.', + l?.liveHrStreamingFix ?? + 'The band streams while it is on your wrist and the app ' + 'is open.' ); - return StatusCard('No live reading', why, + return StatusCard(l?.liveHrNoReadingTitle ?? 'No live reading', why, fix: fix, icon: LucideIcons.heartOff); } } diff --git a/test/live_hr_card_l10n_test.dart b/test/live_hr_card_l10n_test.dart new file mode 100644 index 000000000..21d1fddc0 --- /dev/null +++ b/test/live_hr_card_l10n_test.dart @@ -0,0 +1,74 @@ +// LiveHrCard's empty states and trace caption must follow the app locale. +// StatusCard only translates strings its lookup tables already know, so a +// raw English literal here showed an English card on a translated screen. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _page(AppState app, Locale locale, Widget child) => + ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(Brightness.light), + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ); + +void main() { + const english = [ + 'No live reading', + 'No band is paired.', + 'Pair one from Profile to read live beats.', + ]; + + for (final code in ['de', 'ru', 'zh']) { + testWidgets('unpaired empty state is translated ($code)', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final l = lookupAppLocalizations(Locale(code)); + + await t.pumpWidget(_page(app, Locale(code), const LiveHrCard())); + await t.pumpAndSettle(); + + for (final s in english) { + expect(find.textContaining(s), findsNothing, reason: s); + } + expect(find.textContaining(l.liveHrNoReadingTitle), findsWidgets); + expect(find.textContaining(l.liveHrNotPairedWhy), findsWidgets); + }); + + testWidgets('trace caption is translated ($code)', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + final l = lookupAppLocalizations(Locale(code)); + + await t.pumpWidget(_page( + app, + Locale(code), + const LiveHrCard.preview(hr: 64, trace: [60, 62, 64, 66]), + )); + await t.pumpAndSettle(); + + expect(find.textContaining('Not stored'), findsNothing); + expect(find.text(l.liveHrTraceCaption(4, 60, 66)), findsOneWidget); + }); + } + + test('every locale carries the live HR strings', () { + for (final locale in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(locale); + final en = lookupAppLocalizations(const Locale('en')); + if (locale.languageCode == 'en') continue; + expect(l.liveHrNoReadingTitle, isNot(en.liveHrNoReadingTitle), + reason: locale.toString()); + expect(l.liveHrNoRecentBeatWhy(30), contains('30')); + } + }); +} From cda2834a93dca64454542379b3660302340c9561 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:20:44 +0530 Subject: [PATCH 15/72] elevation stayed in metres for imperial users, right next to miles a hike set to imperial showed '3.12 mi' with '+120 m climbed' under it, and the lone m reads as miles. the climb, gain, loss, peak and the elevation chart on the summary and the share card now follow the unit setting (feet when imperial). the hero caption gets its own feet string in every locale. (cherry picked from commit e3d654155bcb92ce50b661079887646a9acb7255) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 9 ++++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 8 ++++++ lib/l10n/app_zh.arb | 1 + lib/state/units_controller.dart | 11 ++++++++ lib/ui2/activity/share.dart | 4 +-- lib/ui2/activity/summary.dart | 49 +++++++++++++++++++++++++-------- test/ui2_activity_test.dart | 47 +++++++++++++++++++++++++++++++ 11 files changed, 120 insertions(+), 13 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e8c4f5911..d8d74b574 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2015,6 +2015,7 @@ "activitySummaryTotalVolume": "Gesamtvolumen", "activitySummaryElapsedTime": "Gesamtzeit", "activitySummaryClimbed": "+{m} m Anstieg", + "activitySummaryClimbedFt": "+{ft} ft Anstieg", "activitySummaryLapsCaption": "{n, plural, one{{n} Bahn} other{{n} Bahnen}}", "activitySummaryNoRouteTitle": "Keine Route für dieses Training", "activitySummaryNoRouteBody": "Der Standort war deaktiviert, oder diese Aktivität wurde nicht mit GPS aufgezeichnet.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ede113c3..8dda11c9e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -10125,6 +10125,15 @@ } } }, + "activitySummaryClimbedFt": "+{ft} ft climbed", + "@activitySummaryClimbedFt": { + "description": "Hero caption showing feet climbed on a journey/hike session, for imperial units", + "placeholders": { + "ft": { + "type": "int" + } + } + }, "activitySummaryLapsCaption": "{n, plural, one{{n} lap} other{{n} laps}}", "@activitySummaryLapsCaption": { "description": "Hero caption showing lap count on a swim/row session", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b1a319277..3fea2aa26 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1964,6 +1964,7 @@ "activitySummaryTotalVolume": "Volumen total", "activitySummaryElapsedTime": "Tiempo transcurrido", "activitySummaryClimbed": "+{m} m de ascenso", + "activitySummaryClimbedFt": "+{ft} ft de ascenso", "activitySummaryLapsCaption": "{n, plural, one{{n} largo} other{{n} largos}}", "activitySummaryNoRouteTitle": "No hay ruta para esta sesión", "activitySummaryNoRouteBody": "La ubicación estaba desactivada, o esta actividad no se registró con GPS.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5943dcfb8..f182f237e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1952,6 +1952,7 @@ "activitySummaryTotalVolume": "Volume total", "activitySummaryElapsedTime": "Temps écoulé", "activitySummaryClimbed": "+{m} m de dénivelé", + "activitySummaryClimbedFt": "+{ft} pi de dénivelé", "activitySummaryLapsCaption": "{n, plural, one{{n} longueur} other{{n} longueurs}}", "activitySummaryNoRouteTitle": "Aucun itinéraire pour cette séance", "activitySummaryNoRouteBody": "La localisation était désactivée, ou cette activité n'a pas été enregistrée avec le GPS.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1030478a4..ebf815bb4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1952,6 +1952,7 @@ "activitySummaryTotalVolume": "कुल आयतन", "activitySummaryElapsedTime": "बीता हुआ समय", "activitySummaryClimbed": "+{m} मी चढ़ाई", + "activitySummaryClimbedFt": "+{ft} फ़ुट चढ़ाई", "activitySummaryLapsCaption": "{n, plural, one{{n} चक्कर} other{{n} चक्कर}}", "activitySummaryNoRouteTitle": "इस सत्र के लिए कोई मार्ग नहीं है", "activitySummaryNoRouteBody": "लोकेशन बंद थी, या यह गतिविधि GPS के साथ दर्ज नहीं की गई।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7822433fb..232b8cf31 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4710,6 +4710,14 @@ } } }, + "activitySummaryClimbedFt": "Набор высоты: +{ft} фт", + "@activitySummaryClimbedFt": { + "placeholders": { + "ft": { + "type": "int" + } + } + }, "activitySummaryLapsCaption": "{n, plural, one{{n} круг} few{{n} круга} many{{n} кругов} other{{n} круга}}", "@activitySummaryLapsCaption": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9fa3651f8..205f90eb7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1952,6 +1952,7 @@ "activitySummaryTotalVolume": "总训练量", "activitySummaryElapsedTime": "已用时间", "activitySummaryClimbed": "+{m} 米爬升", + "activitySummaryClimbedFt": "+{ft} 英尺爬升", "activitySummaryLapsCaption": "{n, plural, other{{n} 趟}}", "activitySummaryNoRouteTitle": "此次记录没有路线", "activitySummaryNoRouteBody": "定位功能未开启,或此次活动未使用 GPS 记录。", diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index b61d29c43..09dd8adb7 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -18,6 +18,7 @@ class UnitsController extends ChangeNotifier { static const double _cmPerIn = 2.54; static const double _metersPerKm = 1000.0; static const double _metersPerMile = 1609.344; + static const double _metersPerFoot = 0.3048; UnitSystem _system; UnitsController._(this._system); @@ -88,6 +89,16 @@ class UnitsController extends ChangeNotifier { /// "/km" / "/mi". String get paceUnit => '/$distanceUnit'; + // ── elevation (GPS altitude) ─────────────────────────────────────────────── + // Its own unit, not the distance one: a climb in miles is unreadable, and a + // bare "m" printed under "3.12 mi" reads as miles to an imperial user. + /// "m" / "ft". + String get elevationUnit => isImperial ? 'ft' : 'm'; + + /// Elevation in the user's unit (m or ft), unformatted. + double elevationValue(double meters) => + isImperial ? meters / _metersPerFoot : meters; + /// Above this, a "pace" is meaningless noise, not a real number — e.g. a /// few metres of GPS jitter (stationary) divided into real elapsed time /// produces something like 1000 min/km. 60 min/km is already far slower diff --git a/lib/ui2/activity/share.dart b/lib/ui2/activity/share.dart index db9fac85a..53e62a49b 100644 --- a/lib/ui2/activity/share.dart +++ b/lib/ui2/activity/share.dart @@ -434,7 +434,7 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ if (r.avgHr != null) ('Heart rate', '${r.avgHr} bpm'), // With its unit. A bare "612" on a card is a number nobody can read back. if (r.calories != null) ('Calories', '${grouped(r.calories!)} kcal'), - if (r.gainM != null) ('Elevation', '+${r.gainM!.round()} m'), + if (r.gainM != null) ('Elevation', '+${elevationText(r.gainM!, u)}'), if (r.strength.volumeKg != null) ( 'Volume', @@ -459,7 +459,7 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ u?.distanceUnit ?? 'km', r.gainM == null ? r.activity.name - : '+${r.gainM!.round()} m elevation', + : '+${elevationText(r.gainM!, u)} elevation', ), Arch.strength => r.strength.volumeKg == null diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 7fd6d6c1d..a425e729a 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -546,6 +546,11 @@ String grouped(num v) => NumberFormat.decimalPattern( LocaleController.displayLanguageCode, ).format(v.round()); +/// "120 m" / "394 ft" — an elevation in the reader's unit, grouped. Null [u] +/// (a golden) is metric, which is what the store holds. +String elevationText(double meters, UnitsController? u) => + '${grouped(u?.elevationValue(meters) ?? meters)} ${u?.elevationUnit ?? 'm'}'; + // ── THE SUPPORTING STATS ─────────────────────────────────────────────────── /// The glyph for a stat name. @@ -605,7 +610,8 @@ List<(String, String)> sessionStats(ActivityResult r, UnitsController? u) { case Arch.laps: add('Laps', r.lapCount?.toString()); case Arch.journey: - add('Elevation', r.gainM == null ? null : '+${r.gainM!.round()} m'); + add('Elevation', + r.gainM == null ? null : '+${elevationText(r.gainM!, u)}'); case Arch.interval: add('Rounds', r.rounds.isEmpty ? null : '${r.rounds.length}'); case Arch.flow: @@ -789,6 +795,27 @@ class _ActivitySummaryState extends State { String get _distanceUnit => _u?.distanceUnit ?? 'km'; + /// The hero caption under a journey's distance. Feet get their own string + /// rather than a unit placeholder: several locales spell the metre out + /// ("米", "मी", "м") inside the sentence. + String _climbed(AppLocalizations? l, double gainM) { + final u = _u; + if (u != null && u.isImperial) { + final ft = u.elevationValue(gainM).round(); + return l?.activitySummaryClimbedFt(ft) ?? '+$ft ft climbed'; + } + return l?.activitySummaryClimbed(gainM.round()) ?? + '+${gainM.round()} m climbed'; + } + + /// An altitude series in the user's elevation unit. + List _elevSeries(List m) { + final u = _u; + return u == null ? m : [for (final v in m) u.elevationValue(v)]; + } + + String get _elevUnit => _u?.elevationUnit ?? 'm'; + // ─────────────────── TS-09 · SESSION RPE ─────────────────── // // Scores the sessions heart rate cannot see — lifting, climbing, anything @@ -1276,8 +1303,7 @@ class _ActivitySummaryState extends State { _distance!.$1.toStringAsFixed(2), _distance!.$2, arch == Arch.journey && r.gainM != null - ? (l?.activitySummaryClimbed(r.gainM!.round()) ?? - '+${r.gainM!.round()} m climbed') + ? _climbed(l, r.gainM!) : a.displayName(context), ), Arch.strength => r.strength.volumeKg == null @@ -1526,35 +1552,36 @@ class _ActivitySummaryState extends State { ), ]; } + final elev = _elevSeries(r.elevationM); final peak = r.elevationM.reduce((x, y) => x > y ? x : y); - final axis = AxisSpec.of(r.elevationM); + final axis = AxisSpec.of(elev); return [ Surface( child: Column(children: [ ChartFrame( title: l?.activitySummaryElevationTitle ?? 'Elevation', - unit: 'm', + unit: _elevUnit, height: 130, yAxis: axis, xLabels: [ l?.activitySummaryStart ?? 'Start', l?.activitySummaryFinish ?? 'Finish', ], - series: r.elevationM, + series: elev, child: CustomPaint( size: Size.infinite, - painter: Elevation(r.elevationM, p.on(C.green), + painter: Elevation(elev, p.on(C.green), markerInk: p.card, axis: axis)), ), const SizedBox(height: S.x4), InlineMetrics([ if (r.gainM != null) (l?.activitySummaryGain ?? 'Gain', - '+${r.gainM!.round()} m', C.green), + '+${elevationText(r.gainM!, _u)}', C.green), if (r.lossM != null) (l?.activitySummaryLoss ?? 'Loss', - '−${r.lossM!.round()} m', C.orange), - (l?.activitySummaryPeak ?? 'Peak', '${grouped(peak)} m', + '−${elevationText(r.lossM!, _u)}', C.orange), + (l?.activitySummaryPeak ?? 'Peak', elevationText(peak, _u), C.n500), ]), ]), @@ -2186,7 +2213,7 @@ class _ActivitySummaryState extends State { final series = <(String, String, Color, List)>[ if (r.hr.length > 1) ('Heart rate', 'bpm', C.red, r.hr), if (r.elevationM.length > 1) - ('Elevation', 'm', C.teal, r.elevationM), + ('Elevation', _elevUnit, C.teal, _elevSeries(r.elevationM)), ]; if (series.isEmpty) { // Same distinction the overview draws: a session with one minute in it diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index e1721108c..3ea09c4a8 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -427,6 +427,24 @@ void main() { expect(hero.$1, grouped(volumeLb)); }); + test('a climb follows the unit setting: feet next to miles, never a ' + 'bare m that reads as miles', () { + final r = ActivityResult(_first(Arch.journey), + start: _start, + duration: const Duration(hours: 1), + distanceKm: 5.02, + gainM: 120); + final imperial = UnitsController.seed(UnitSystem.imperial); + final metric = UnitsController.seed(UnitSystem.metric); + + expect(sessionStats(r, imperial), contains(('Elevation', '+394 ft'))); + expect(sessionStats(r, metric), contains(('Elevation', '+120 m'))); + expect(shareStats(r, imperial).firstWhere((s) => s.$1 == 'Elevation').$2, + '+394 ft'); + expect(shareHero(r, imperial).$3, '+394 ft elevation'); + expect(shareHero(r, metric).$3, '+120 m elevation'); + }); + test('a bodyweight-only session has no volume, not zero volume', () { final log = StrengthLog([ LoggedSet('pull_up', 8, at: _start), @@ -1010,6 +1028,35 @@ void main() { }); } + testWidgets('imperial: a hike reads its climb in feet under its miles', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2200 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + await tester.pumpWidget(_frame( + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.imperial), + child: ActivitySummary(ActivityResult(_first(Arch.journey), + start: _start, + duration: const Duration(hours: 1), + distanceKm: 5.02, + gainM: 120, + lossM: 30, + elevationM: const [100, 160, 220, 190])), + ), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('+394 ft climbed'), findsOneWidget); + expect(find.textContaining('m climbed'), findsNothing); + expect(find.text('+394 ft'), findsWidgets); + expect(find.text('−98 ft'), findsOneWidget); + expect(find.text('722 ft'), findsOneWidget); + }); + // ── MT-08 · heat and cold ──────────────────────────────────────────── // // The card is built AROUND its no-signal state. Cold water closes the From 094c3eb11e53beeda208311c5d6f803438cbabe6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:23:44 +0530 Subject: [PATCH 16/72] strain printed with a '.' on home, day strain, workouts and live while the strain trend screen used the locale's separator, so a german user saw 12.3 on one screen and 12,3 on the next. those call sites now go through displayFixed like the trend screen does. (cherry picked from commit 8d22d57654d391290472645f0fec5530e3d5fc89) --- lib/ui2/activity/day_strain.dart | 7 ++++--- lib/ui2/activity/live.dart | 3 ++- lib/ui2/profile/wearable_numbers.dart | 3 ++- lib/ui2/screens/home_screen.dart | 12 +++++++----- lib/ui2/screens/workout_screen.dart | 3 ++- test/strain_decimal_separator_test.dart | 25 +++++++++++++++++++++++++ 6 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 test/strain_decimal_separator_test.dart diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index 77856f91b..aad3180bd 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -25,6 +25,7 @@ import '../../data/day_label.dart'; import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; import '../../l10n/date_text.dart'; +import '../../l10n/decimal_text.dart'; import '../../models/metric.dart' show whyFromNote; import '../screens/home_screen.dart' show repoOf; import '../screens/metric_detail.dart' show detailScaffold; @@ -267,8 +268,8 @@ class _DayStrainDetailState extends State { ? why ?? (l?.dayStrainNoReasonBody ?? 'Nothing recorded says why this day produced no strain.') - : (l?.dayStrainScoredNoTraceBody(s.toStringAsFixed(1)) ?? - 'The day strain is ${s.toStringAsFixed(1)}. The waking minutes ' + : (l?.dayStrainScoredNoTraceBody(displayFixed(s, 1)) ?? + 'The day strain is ${displayFixed(s, 1)}. The waking minutes ' 'it was built from are not stored for this day.'), fix: (s == null && !saw) ? (l?.dayStrainWearBandFix ?? 'Wear the band through the day') @@ -303,7 +304,7 @@ class _DayStrainDetailState extends State { const SizedBox(height: S.x4), InlineMetrics([ if (d.strain != null) - (l?.dayStrainTitle ?? 'Day strain', d.strain!.toStringAsFixed(1), + (l?.dayStrainTitle ?? 'Day strain', displayFixed(d.strain!, 1), C.purple), if (d.peakHr != null) (l?.dayStrainPeakHr ?? 'Peak HR', '${d.peakHr} bpm', C.red), diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 735a307f7..3aac28114 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -32,6 +32,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../gps/gps_source.dart' show GpsPermissionStatus; import '../../l10n/app_localizations.dart'; import '../../l10n/display_text.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/prefs.dart'; import '../../state/units_controller.dart'; import '../screens/home_screen.dart' show unitsOf; @@ -987,7 +988,7 @@ List<(String, String)> _commonStats( return [ if (kcal != null) ('$kcal', l?.activityLiveKcalEstUnit ?? 'kcal · est'), if (feed.strain != null) - (feed.strain!.toStringAsFixed(1), l?.activityLiveStrainUnit ?? 'strain'), + (displayFixed(feed.strain!, 1), l?.activityLiveStrainUnit ?? 'strain'), if (feed.steps != null) ('${feed.steps}', l?.activityLiveStepsUnit ?? 'steps'), ]; } diff --git a/lib/ui2/profile/wearable_numbers.dart b/lib/ui2/profile/wearable_numbers.dart index f048b536f..38af31dd9 100644 --- a/lib/ui2/profile/wearable_numbers.dart +++ b/lib/ui2/profile/wearable_numbers.dart @@ -22,6 +22,7 @@ import '../../compute/inputs/validation_bands.dart' show kTruthBandRows; import '../../data/day_label.dart' show todayLabel; import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/prefs.dart'; import '../ui2.dart'; import 'profile.dart'; @@ -267,7 +268,7 @@ String whyText(AppLocalizations l, String? reason, String device) => 'skin_temp' => device ? (fixed1(v), l.wearableUnitCelsius) : ('${v > 0 ? '+' : ''}${fixed1(v)}', l.wearableUnitSd), - 'strain' => (fixed1(v), ''), + 'strain' => (displayFixed(v, 1), ''), 'calories' => (int0(v), l.wearableUnitKcal), // A ring's movement is the share of the night's records with steps in // them; a watch's is minutes in a heart-rate zone. diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index f2d97c89b..d40677d97 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -911,6 +911,8 @@ class RingTrio extends StatelessWidget { } /// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. +String _fixed1OrEmpty(Object? v) => v is num ? displayFixed(v, 1) : ''; + String? recoveryUpdatedLine(Object? update, AppLocalizations? l) { if (update case { 'from': final int from, @@ -1042,7 +1044,7 @@ _RingState _ringOf(HomeRingKind k, HomeData d, AppLocalizations? l) { ? _gap(k, l?.homeRingStrain ?? 'Strain', LucideIcons.zap, C.purple, d.strain, l?.homeRingNoStrain ?? 'No strain', l, unit: 'days') : _RingState(k, l?.homeRingStrain ?? 'Strain', LucideIcons.zap, C.purple, - value: v.toStringAsFixed(1), sub: l?.homeStrainOf21 ?? 'of 21', frac: v / 21); + value: displayFixed(v, 1), sub: l?.homeStrainOf21 ?? 'of 21', frac: v / 21); case HomeRingKind.sleep: final v = d.sleepMin.value; final need = d.sleepNeedMin.value; @@ -2329,8 +2331,8 @@ class _HomeScreenState extends State with RevisionReload { C.purple, met ? (l?.homeStrainTargetMet ?? 'Strain target met') - : (l?.homeAimForStrain(aim.toStringAsFixed(1)) ?? - 'Aim for ${aim.toStringAsFixed(1)} strain'), + : (l?.homeAimForStrain(displayFixed(aim, 1)) ?? + 'Aim for ${displayFixed(aim, 1)} strain'), // Built on a wearable's estimated TRIMP: say so. [ l?.homeTraining ?? 'Training', @@ -2338,8 +2340,8 @@ class _HomeScreenState extends State with RevisionReload { ].join(' · '), met ? (l?.actionDone ?? 'Done') - : '${(target['low'] as num?)?.toStringAsFixed(1) ?? ''}–' - '${(target['high'] as num?)?.toStringAsFixed(1) ?? ''}', + : '${_fixed1OrEmpty(target['low'])}–' + '${_fixed1OrEmpty(target['high'])}', met)); } diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 92c2e102c..7ac6c244d 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -26,6 +26,7 @@ import '../../health/health_import_state.dart'; import '../../health/auto_workout_import.dart'; import '../../health/health_workout_import.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../models/metric.dart'; import '../../state/app_state.dart'; import '../../state/clock_format.dart' show formatClockOf; @@ -1094,7 +1095,7 @@ class _HistoryRow extends StatelessWidget { ]), ), if (w.strain != null) ...[ - Text(w.strain!.toStringAsFixed(1), + Text(displayFixed(w.strain!, 1), style: F.n17.copyWith(color: p.ink)), const SizedBox(width: S.x1), Padding( diff --git a/test/strain_decimal_separator_test.dart b/test/strain_decimal_separator_test.dart new file mode 100644 index 000000000..a0bd99064 --- /dev/null +++ b/test/strain_decimal_separator_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/l10n/decimal_text.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/profile/wearable_numbers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // The strain trend screen formats through displayFixed; every other place + // that prints the same strain number has to use the same separator, or a + // German user sees "12.3" on one screen and "12,3" on the next. + test('strain cells use the display locale decimal separator', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('de')..useForPresentation(); + addTearDown(controller.dispose); + final de = lookupAppLocalizations(const Locale('de')); + + expect(displayFixed(12.3, 1), '12,3'); + expect(formatCell(de, 'strain', 12.3).$1, displayFixed(12.3, 1)); + }); +} From bc87afc36741e725d334b7591d1d06381db9ea2b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:54:07 +0530 Subject: [PATCH 17/72] the session summary still printed strain with a '.', so in german the workout row read 12,3 and the summary it opens read 12.3. the summary now uses displayFixed too, the home helper sits above the recovery doc comment it had split off, and a test keeps toStringAsFixed out of the strain call sites. (cherry picked from commit e5742a516d6518dcc13ba0981049c56ee6ea4c21) --- lib/ui2/activity/summary.dart | 3 +- lib/ui2/screens/home_screen.dart | 2 +- test/strain_decimal_separator_test.dart | 48 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index a425e729a..ce281ce73 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -22,6 +22,7 @@ import '../../l10n/display_text.dart'; import '../../state/locale_controller.dart'; import 'package:intl/intl.dart'; import '../../l10n/date_text.dart'; +import '../../l10n/decimal_text.dart'; import 'dart:convert' show utf8; import 'dart:typed_data' show Uint8List; @@ -648,7 +649,7 @@ List<(String, String)> sessionStats(ActivityResult r, UnitsController? u) { add('VO2max (est.)', r.vo2max == null ? null : '${r.vo2max!.toStringAsFixed(1)} ml/kg/min'); add('Calories', r.calories == null ? null : '${grouped(r.calories!)} kcal'); - add('Strain', r.strain?.toStringAsFixed(1)); + add('Strain', r.strain == null ? null : displayFixed(r.strain!, 1)); // TS-09 — last, under the measurements, and named 'Your rating' rather than // 'RPE' so the row cannot read as something the band found out. It is not on // the share card: `shareStats` is its own list and a self-report is not one diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index d40677d97..36ceadb3c 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -910,9 +910,9 @@ class RingTrio extends StatelessWidget { } } -/// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. String _fixed1OrEmpty(Object? v) => v is num ? displayFixed(v, 1) : ''; +/// "Updated 8:12 · 2 → 28" off a `todayHeadlineOf` `recovery_update`, or null. String? recoveryUpdatedLine(Object? update, AppLocalizations? l) { if (update case { 'from': final int from, diff --git a/test/strain_decimal_separator_test.dart b/test/strain_decimal_separator_test.dart index a0bd99064..42f2d720e 100644 --- a/test/strain_decimal_separator_test.dart +++ b/test/strain_decimal_separator_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -5,6 +7,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/l10n/decimal_text.dart'; import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/activity/summary.dart'; import 'package:openstrap_edge/ui2/profile/wearable_numbers.dart'; void main() { @@ -22,4 +26,48 @@ void main() { expect(displayFixed(12.3, 1), '12,3'); expect(formatCell(de, 'strain', 12.3).$1, displayFixed(12.3, 1)); }); + + // The workout list and the live screen both open the session summary; the + // summary has to print the strain they printed, not a '.' version of it. + test('the session summary prints strain like the list that opens it', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('de')..useForPresentation(); + addTearDown(controller.dispose); + final r = ActivityResult( + allActivities.first, + start: DateTime(2026, 5, 20, 7, 15), + duration: const Duration(minutes: 45), + strain: 12.3, + ); + final strain = sessionStats(r, null).firstWhere((s) => s.$1 == 'Strain'); + expect(strain.$2, '12,3'); + }); + + // Every on-screen strain number. A source scan, because these sit inside + // widgets that need a full app around them to pump, and the regression is + // one `toStringAsFixed` slipping back in. + test('no screen formats a strain value with toStringAsFixed', () { + const files = [ + 'lib/ui2/screens/home_screen.dart', + 'lib/ui2/screens/workout_screen.dart', + 'lib/ui2/activity/day_strain.dart', + 'lib/ui2/activity/live.dart', + 'lib/ui2/activity/summary.dart', + ]; + final offenders = []; + for (final f in files) { + final lines = File(f).readAsLinesSync(); + for (var i = 0; i < lines.length; i++) { + final window = [ + if (i > 0) lines[i - 1], + lines[i], + ].join(' '); + if (lines[i].contains('toStringAsFixed') && + RegExp(r'strain|Strain|aim\b').hasMatch(window)) { + offenders.add('$f:${i + 1}: ${lines[i].trim()}'); + } + } + } + expect(offenders, isEmpty); + }); } From bb5e4c5edd2702e1be631a91cf1dfa4696deb22e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:19:31 +0530 Subject: [PATCH 18/72] cycle prediction said "in 1 days" and "1 days late" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the lead clauses under the next-period header were plain {days} placeholders, so a period due tomorrow or one day overdue read "in 1 days" / "1 days late" (and "dans 1 jours", "1 Tage überfällig", "1 días de retraso"). they are icu plurals now in en/de/fr/es, plus hindi's "in n days". ru and zh already read fine. (cherry picked from commit 28fdc179d0c66a62d6c3b982554af38fee7ec57b) --- lib/l10n/app_de.arb | 6 ++--- lib/l10n/app_en.arb | 6 ++--- lib/l10n/app_es.arb | 6 ++--- lib/l10n/app_fr.arb | 6 ++--- lib/l10n/app_hi.arb | 2 +- test/cycle_lead_plural_test.dart | 46 ++++++++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 test/cycle_lead_plural_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d8d74b574..c20751428 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1211,13 +1211,13 @@ "cycleNextPeriodBetween": "NÄCHSTE PERIODE, ERWARTET ZWISCHEN", "cycleNextPeriodAround": "NÄCHSTE PERIODE, ERWARTET ETWA", "cycleFromOneMeasuredGap": "basierend auf einem einzigen gemessenen Zyklus – daran lässt sich nicht erkennen, wie stark dein Zyklus schwankt", - "cyclePastEndOfIt": "{days} Tage nach Ende des Zeitraums · ", + "cyclePastEndOfIt": "{days, plural, one{1 Tag nach Ende des Zeitraums} other{{days} Tage nach Ende des Zeitraums}} · ", "cycleInsideItNow": "du bist gerade in diesem Zeitraum · ", "cycleInDaysRange": "in {lo}–{hi} Tagen · ", "cycleHalfOfMeasuredGaps": "die Hälfte deiner {n} gemessenen Zyklen lag innerhalb einer so breiten Spanne", - "cycleLeadDaysLate": "{days} Tage überfällig · ", + "cycleLeadDaysLate": "{days, plural, one{1 Tag überfällig} other{{days} Tage überfällig}} · ", "cycleLeadToday": "heute · ", - "cycleLeadInDays": "in {days} Tagen · ", + "cycleLeadInDays": "{days, plural, one{in 1 Tag} other{in {days} Tagen}} · ", "cycleWhatYouUsuallyNotice": "Was dir normalerweise auffällt", "cycleSymptomShapeSummary": "Vier Werte, einer pro Zykluswoche, zurückgerechnet ab deinen eingetragenen Periodenbeginnen. Du hast über {cycles} Zyklen an {daysByWeek} Tagen pro Woche etwas eingetragen – nur diese Tage fließen hier ein.", "cycleRemoveLoggedDay": "{date} entfernen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8dda11c9e..900098abf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5920,7 +5920,7 @@ "@cycleFromOneMeasuredGap": { "description": "Trailing clause of the prediction footnote when only one cycle gap has been measured." }, - "cyclePastEndOfIt": "{days} days past the end of it · ", + "cyclePastEndOfIt": "{days, plural, one{1 day past the end of it} other{{days} days past the end of it}} · ", "@cyclePastEndOfIt": { "description": "Leading clause of the prediction footnote: the predicted window has already passed.", "placeholders": { @@ -5954,7 +5954,7 @@ } } }, - "cycleLeadDaysLate": "{days} days late · ", + "cycleLeadDaysLate": "{days, plural, one{1 day late} other{{days} days late}} · ", "@cycleLeadDaysLate": { "description": "Leading clause used when the single measured-gap prediction is overdue.", "placeholders": { @@ -5967,7 +5967,7 @@ "@cycleLeadToday": { "description": "Leading clause used when the single measured-gap prediction is due today." }, - "cycleLeadInDays": "in {days} days · ", + "cycleLeadInDays": "{days, plural, one{in 1 day} other{in {days} days}} · ", "@cycleLeadInDays": { "description": "Leading clause used when the single measured-gap prediction is still ahead.", "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3fea2aa26..7c7e7f597 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1176,13 +1176,13 @@ "cycleNextPeriodBetween": "PRÓXIMO PERÍODO, PREVISTO ENTRE", "cycleNextPeriodAround": "PRÓXIMO PERÍODO, PREVISTO ALREDEDOR DE", "cycleFromOneMeasuredGap": "a partir de tu único intervalo medido, que no puede mostrar cuánto varía tu propio ciclo", - "cyclePastEndOfIt": "{days} días después del final · ", + "cyclePastEndOfIt": "{days, plural, one{1 día después del final} other{{days} días después del final}} · ", "cycleInsideItNow": "ahora estás dentro de él · ", "cycleInDaysRange": "en {lo}–{hi} días · ", "cycleHalfOfMeasuredGaps": "la mitad de tus {n} intervalos medidos cayó dentro de un rango tan amplio como este", - "cycleLeadDaysLate": "{days} días de retraso · ", + "cycleLeadDaysLate": "{days, plural, one{1 día de retraso} other{{days} días de retraso}} · ", "cycleLeadToday": "hoy · ", - "cycleLeadInDays": "en {days} días · ", + "cycleLeadInDays": "{days, plural, one{en 1 día} other{en {days} días}} · ", "cycleWhatYouUsuallyNotice": "Lo que sueles notar", "cycleSymptomShapeSummary": "Cuatro números, uno por cada semana del ciclo, contados a partir de tus propios inicios registrados. Registraste algo en {daysByWeek} días de cada semana a lo largo de {cycles} ciclos: esos son los únicos días que se tienen en cuenta aquí.", "cycleRemoveLoggedDay": "Eliminar {date}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f182f237e..dc38685a8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1176,13 +1176,13 @@ "cycleNextPeriodBetween": "PROCHAINES RÈGLES, PRÉVUES ENTRE", "cycleNextPeriodAround": "PROCHAINES RÈGLES, PRÉVUES VERS", "cycleFromOneMeasuredGap": "d'après votre seul intervalle mesuré, qui ne peut pas indiquer à quel point votre cycle varie", - "cyclePastEndOfIt": "{days} jours après sa fin · ", + "cyclePastEndOfIt": "{days, plural, one{{days} jour après sa fin} other{{days} jours après sa fin}} · ", "cycleInsideItNow": "vous êtes actuellement dedans · ", "cycleInDaysRange": "dans {lo} à {hi} jours · ", "cycleHalfOfMeasuredGaps": "la moitié de vos {n} intervalles mesurés se situait dans une fourchette aussi large que celle-ci", - "cycleLeadDaysLate": "{days} jours de retard · ", + "cycleLeadDaysLate": "{days, plural, one{{days} jour de retard} other{{days} jours de retard}} · ", "cycleLeadToday": "aujourd'hui · ", - "cycleLeadInDays": "dans {days} jours · ", + "cycleLeadInDays": "{days, plural, one{dans {days} jour} other{dans {days} jours}} · ", "cycleWhatYouUsuallyNotice": "Ce que vous remarquez habituellement", "cycleSymptomShapeSummary": "Quatre chiffres, un par semaine du cycle, comptés depuis vos propres débuts enregistrés. Vous avez enregistré quelque chose {daysByWeek} jours de chaque semaine sur {cycles} cycles — ce sont les seuls jours pris en compte ici.", "cycleRemoveLoggedDay": "Supprimer {date}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ebf815bb4..db789740d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1182,7 +1182,7 @@ "cycleHalfOfMeasuredGaps": "आपके {n} मापे गए अंतरालों में से आधे इतनी ही चौड़ी सीमा के भीतर आए", "cycleLeadDaysLate": "{days} दिन देर से · ", "cycleLeadToday": "आज · ", - "cycleLeadInDays": "{days} दिनों में · ", + "cycleLeadInDays": "{days, plural, one{{days} दिन में} other{{days} दिनों में}} · ", "cycleWhatYouUsuallyNotice": "आप आमतौर पर क्या महसूस करती हैं", "cycleSymptomShapeSummary": "चार आँकड़े, साइकल के हर हफ़्ते के लिए एक, आपकी अपनी दर्ज शुरुआतों से गिने गए। {cycles} साइकल में आपने हर हफ़्ते {daysByWeek} दिन कुछ न कुछ दर्ज किया — यही एकमात्र दिन हैं जो यहाँ गिने जाते हैं।", "cycleRemoveLoggedDay": "{date} हटाएँ", diff --git a/test/cycle_lead_plural_test.dart b/test/cycle_lead_plural_test.dart new file mode 100644 index 000000000..7db84cc00 --- /dev/null +++ b/test/cycle_lead_plural_test.dart @@ -0,0 +1,46 @@ +// The cycle prediction lead clause is shown for "one day away" and "one day +// overdue" at every cycle boundary, so a plain "{days} days" placeholder read +// "in 1 days" / "1 days late" in most locales. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; + +void main() { + AppLocalizations l(String code) => lookupAppLocalizations(Locale(code)); + + test('cycle lead clauses use the singular for one day', () { + final expected = >{ + 'en': ['in 1 day · ', '1 day late · ', '1 day past the end of it · '], + 'de': [ + 'in 1 Tag · ', + '1 Tag überfällig · ', + '1 Tag nach Ende des Zeitraums · ', + ], + 'fr': ['dans 1 jour · ', '1 jour de retard · ', '1 jour après sa fin · '], + 'es': [ + 'en 1 día · ', + '1 día de retraso · ', + '1 día después del final · ', + ], + }; + expected.forEach((code, want) { + final s = l(code); + expect( + [s.cycleLeadInDays(1), s.cycleLeadDaysLate(1), s.cyclePastEndOfIt(1)], + want, + reason: code, + ); + }); + expect(l('hi').cycleLeadInDays(1), '1 दिन में · '); + }); + + test('cycle lead clauses keep the plural for more than one day', () { + final en = l('en'); + expect(en.cycleLeadInDays(3), 'in 3 days · '); + expect(en.cycleLeadDaysLate(2), '2 days late · '); + expect(en.cyclePastEndOfIt(4), '4 days past the end of it · '); + expect(l('fr').cycleLeadInDays(2), 'dans 2 jours · '); + expect(l('de').cycleLeadInDays(2), 'in 2 Tagen · '); + expect(l('hi').cycleLeadInDays(2), '2 दिनों में · '); + }); +} From d5022468f0e1b12a1064a66e177bd5f5ca3113ce Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 17 Oct 2026 19:20:11 +0530 Subject: [PATCH 19/72] the health tab stress row printed the raw analytics level ('elevated') in every language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wellness already mapped that token to the localized word through a private helper; i made it shared and the health row goes through it now, so a german row reads 'erhöht' like wellness does. (cherry picked from commit c8420207dbeacca2b16c64460334b9cdca732b93) --- lib/ui2/screens/health_screen.dart | 6 +++++- lib/ui2/screens/wellness_screen.dart | 5 +++-- test/stress_level_label_test.dart | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 test/stress_level_label_test.dart diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 99938963a..3a8b01ab5 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -774,7 +774,11 @@ class _HealthScreenState extends State with RevisionReload { LucideIcons.brain, C.purple, l?.healthRowStress ?? 'Stress', - ofNight((stressBlock is Map ? stressBlock['level']?.toString() : null) ?? + // The level is analytics' English token; it goes through the same + // localized mapping Wellness uses, or a German row read 'elevated'. + ofNight((stressBlock is Map && stressBlock['level'] is String + ? stressLevelLabel(l, stressBlock['level'] as String) + : null) ?? (l?.healthRowStress ?? 'Stress')), stressScore == null ? '' : '${stressScore.round()}', // 0–100, and the scale has to be on the row. Wellness has always shown diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index dc50a010a..931ff5da8 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -381,7 +381,7 @@ class _WellnessScreenState extends State with RevisionReload { final stress = _stress['stress']; final score = _reading(stress is Map ? stress['score'] : null); final level = stress is Map && stress['level'] is String - ? _stressLevelLabel(l, stress['level'] as String) + ? stressLevelLabel(l, stress['level'] as String) : null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1242,7 +1242,8 @@ class DriverRow extends StatelessWidget { /// The pinned analytics package returns 'low'/'normal'/'elevated'/'high' — /// English regardless of locale. Map to the localized word before display. -String? _stressLevelLabel(AppLocalizations? l, String raw) { +/// Shared with the Health tab's stress row, which printed the raw token. +String? stressLevelLabel(AppLocalizations? l, String raw) { switch (raw) { case 'low': return l?.wellnessStressLevelLow ?? raw; diff --git a/test/stress_level_label_test.dart b/test/stress_level_label_test.dart new file mode 100644 index 000000000..8151b0dd2 --- /dev/null +++ b/test/stress_level_label_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/wellness_screen.dart'; + +void main() { + test('stress level tokens map to the locale, not the analytics English', () { + final de = lookupAppLocalizations(const Locale('de')); + expect(stressLevelLabel(de, 'elevated'), de.wellnessStressLevelElevated); + expect(stressLevelLabel(de, 'elevated'), isNot('elevated')); + expect(stressLevelLabel(de, 'low'), de.wellnessStressLevelLow); + expect(stressLevelLabel(de, 'normal'), de.wellnessStressLevelNormal); + expect(stressLevelLabel(de, 'high'), de.wellnessStressLevelHigh); + }); + + test('unknown token and missing localizations fall back to the raw token', + () { + final de = lookupAppLocalizations(const Locale('de')); + expect(stressLevelLabel(de, 'weird'), 'weird'); + expect(stressLevelLabel(null, 'high'), 'high'); + }); +} From 530a7458772c458836af6f800b4ef7b0996c7918 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:03:32 +0530 Subject: [PATCH 20/72] rename band sheet no longer disposes its field while the dialog is closing the controller and error notifier were disposed as soon as showDialog returned, which happens at pop time while the exit transition is still rebuilding the text field, so every save and cancel threw a used-after-dispose error. the sheet is now its own stateful widget and disposes them with the route. (cherry picked from commit 10480888c9124392a9b7ef34fcfb1f391a740781) --- lib/ui2/profile/devices.dart | 74 +++++++++++++++++++++---------- test/rename_band_dialog_test.dart | 57 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 test/rename_band_dialog_test.dart diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index f89244f28..eb29d746a 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -2193,17 +2193,59 @@ Future _renameBand(BuildContext c, AppState app, String current) async { // Captured before the dialog: `c` is not safe to touch after the await. final messenger = ScaffoldMessenger.maybeOf(c); final l = AppLocalizations.of(c); - final ctl = TextEditingController(text: current); - final err = ValueNotifier(null); final name = await showDialog( context: c, - builder: (d) => AlertDialog( + builder: (_) => RenameBandDialog(current: current), + ); + if (name == null) return; + try { + await app.renameStrap(name); + messenger?.showSnackBar( + SnackBar(content: Text(l?.devicesRenamedTo(name) ?? 'Renamed to $name'))); + } catch (e) { + // The write goes to the strap, so a dropped link loses it. Say that, + // rather than leaving the old name on screen with no explanation. + messenger?.showSnackBar(SnackBar( + content: Text(l?.devicesCouldNotRename(e.toString()) ?? + 'Could not rename the band: $e'))); + } +} + +/// The rename sheet owns its controller and error notifier so they are +/// disposed with the route. `showDialog` completes at pop time, while the exit +/// transition is still rebuilding the field; disposing them right after the +/// await threw "used after being disposed" on every Save and Cancel. +class RenameBandDialog extends StatefulWidget { + const RenameBandDialog({super.key, required this.current}); + + final String current; + + @override + State createState() => _RenameBandDialogState(); +} + +class _RenameBandDialogState extends State { + late final TextEditingController _ctl = + TextEditingController(text: widget.current); + final ValueNotifier _err = ValueNotifier(null); + + @override + void dispose() { + _ctl.dispose(); + _err.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext d) { + final l = AppLocalizations.of(d); + return AlertDialog( title: Text(l?.devicesNameThisBand ?? 'Name this band'), content: Column(mainAxisSize: MainAxisSize.min, children: [ ValueListenableBuilder( - valueListenable: err, + valueListenable: _err, builder: (_, e, _) => TextField( - controller: ctl, + controller: _ctl, autofocus: true, maxLength: 20, decoration: InputDecoration( @@ -2221,11 +2263,11 @@ Future _renameBand(BuildContext c, AppState app, String current) async { child: Text(l?.actionCancel ?? 'Cancel')), TextButton( onPressed: () { - final v = ctl.text.trim(); + final v = _ctl.text.trim(); if (v.isEmpty) { - err.value = l?.devicesGiveItAName ?? 'Give it a name'; + _err.value = l?.devicesGiveItAName ?? 'Give it a name'; } else if (cleanDeviceLabel(v) == null) { - err.value = l?.devicesNameCharsetError ?? + _err.value = l?.devicesNameCharsetError ?? "Only letters, numbers, space, and ' . _ -"; } else { Navigator.of(d).pop(v); @@ -2234,21 +2276,7 @@ Future _renameBand(BuildContext c, AppState app, String current) async { child: Text(l?.actionSave ?? 'Save'), ), ], - ), - ); - ctl.dispose(); - err.dispose(); - if (name == null) return; - try { - await app.renameStrap(name); - messenger?.showSnackBar( - SnackBar(content: Text(l?.devicesRenamedTo(name) ?? 'Renamed to $name'))); - } catch (e) { - // The write goes to the strap, so a dropped link loses it. Say that, - // rather than leaving the old name on screen with no explanation. - messenger?.showSnackBar(SnackBar( - content: Text(l?.devicesCouldNotRename(e.toString()) ?? - 'Could not rename the band: $e'))); + ); } } diff --git a/test/rename_band_dialog_test.dart b/test/rename_band_dialog_test.dart new file mode 100644 index 000000000..163b2f517 --- /dev/null +++ b/test/rename_band_dialog_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart' show RenameBandDialog; + +// showDialog completes at pop time, before the exit transition finishes. The +// rename sheet used to dispose its controller and error notifier right after +// that await, while the field was still rebuilding, which threw on every Save +// and Cancel. Pumping the full transition is what surfaces it. +Future _open(WidgetTester t, void Function(String?) onResult) async { + await t.pumpWidget(MaterialApp( + home: Builder( + builder: (c) => TextButton( + onPressed: () async => onResult(await showDialog( + context: c, + builder: (_) => const RenameBandDialog(current: 'Old'), + )), + child: const Text('open'), + ), + ), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); +} + +void main() { + testWidgets('save returns the trimmed name and survives the pop transition', + (t) async { + String? result; + await _open(t, (r) => result = r); + await t.enterText(find.byType(TextField), ' Left wrist '); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(RenameBandDialog), findsNothing); + expect(result, 'Left wrist'); + }); + + testWidgets('cancel returns null and survives the pop transition', + (t) async { + var result = 'unset' as String?; + await _open(t, (r) => result = r); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(RenameBandDialog), findsNothing); + expect(result, isNull); + }); + + testWidgets('an empty name is refused in the sheet', (t) async { + await _open(t, (_) {}); + await t.enterText(find.byType(TextField), ' '); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + expect(find.byType(RenameBandDialog), findsOneWidget); + expect(find.text('Give it a name'), findsOneWidget); + }); +} From b65ce949c898aed1566a29b86c836811a7623bd1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:07:08 +0530 Subject: [PATCH 21/72] translate settings headers, report an issue and onboarding strings german, spanish, french, hindi and chinese had no translations for the settings group headers (app, data, advanced), the report-an-issue row and the entire report screen, the zone-alert and alarm settings rows, the cycle-tracking onboarding line, the not-a-number snackbar and the height/weight labels, so those screens rendered in english inside an otherwise translated ui. the onboarding unreadable-field list also used a literal 'Age'; it now uses the existing localized age label. (cherry picked from commit d15473e05dae4e1edc342a29230db12e944903bb) --- lib/l10n/app_de.arb | 75 +++++++++++++++++++++++- lib/l10n/app_es.arb | 83 ++++++++++++++++++++++++++- lib/l10n/app_fr.arb | 83 ++++++++++++++++++++++++++- lib/l10n/app_hi.arb | 83 ++++++++++++++++++++++++++- lib/l10n/app_zh.arb | 83 ++++++++++++++++++++++++++- lib/ui2/onboarding/profile_setup.dart | 3 +- test/settings_report_l10n_test.dart | 57 ++++++++++++++++++ 7 files changed, 461 insertions(+), 6 deletions(-) create mode 100644 test/settings_report_l10n_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c20751428..862e9de12 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2015,7 +2015,6 @@ "activitySummaryTotalVolume": "Gesamtvolumen", "activitySummaryElapsedTime": "Gesamtzeit", "activitySummaryClimbed": "+{m} m Anstieg", - "activitySummaryClimbedFt": "+{ft} ft Anstieg", "activitySummaryLapsCaption": "{n, plural, one{{n} Bahn} other{{n} Bahnen}}", "activitySummaryNoRouteTitle": "Keine Route für dieses Training", "activitySummaryNoRouteBody": "Der Standort war deaktiviert, oder diese Aktivität wurde nicht mit GPS aufgezeichnet.", @@ -2801,6 +2800,7 @@ "journalComposeAddToJournal": "Ins Journal übernehmen", "workoutLoadFailedTitle": "Aktivität konnte nicht gelesen werden", "workoutLoadFailedBody": "Deine Einheiten konnten nicht geladen werden. Nichts wurde gelöscht – das ist ein fehlgeschlagener Lesevorgang, keine fehlenden Daten.", + "activitySummaryClimbedFt": "+{ft} ft Anstieg", "liveHrNoReadingTitle": "Kein Live-Wert", "liveHrNotPairedWhy": "Es ist kein Band gekoppelt.", "liveHrNotPairedFix": "Kopple eines im Profil, um Live-Herzschläge zu lesen.", @@ -2813,6 +2813,79 @@ "notifRecoveryReadySleptBody": "Erholung {score}, geschlafen {hours} h {minutes} min.", "notifStepGoalBody": "Du hast etwa {steps} Schritte erreicht – mindestens dein Ziel von {goal}.", "notifLowBatteryBody": "Dein Band ist bei {percent} %. Lade es bald auf.", + "supplementUnreadableFields": "„{fields}“ sind keine Zahlen. Es wurde nichts gespeichert.", + "presentationHeightCm": "Größe (cm)", + "presentationHeightIn": "Größe (in)", + "presentationWeightLb": "Gewicht (lb)", + "supplementUnreadableField": "„{field}“ ist keine Zahl. Es wurde nichts gespeichert.", + "settingsGroupApp": "App", + "settingsGroupData": "Daten", + "settingsGroupAdvanced": "Erweitert", + "settingsCycleTrackingRowSubHealth": "Fügt „Zyklus“ unter „Gesundheit“ hinzu. Ausgeschaltet wird es ausgeblendet, bisherige Einträge bleiben erhalten", + "profileSetupCycleSub": "Trag deine Periode unter „Gesundheit“ ein. Das lässt sich später ändern.", + "settingsReportIssueRowTitle": "Problem melden", + "settingsReportIssueRowSub": "Sendet, was du auswählst, verschlüsselt an die Entwickler", + "reportNavTitle": "Problem melden", + "reportCategoryGroup": "Worum es geht", + "reportCategorySync": "Synchronisierung", + "reportCategorySleep": "Schlaf", + "reportCategoryRecovery": "Erholung", + "reportCategoryWorkout": "Training", + "reportCategoryPairing": "Kopplung", + "reportCategoryOther": "Sonstiges", + "reportTitleLabel": "Titel", + "reportTitleHint": "Eine Zeile dazu, was schiefging", + "reportDescriptionLabel": "Was passiert ist", + "reportDescriptionHint": "Was du getan hast, was du erwartet hast, was du gesehen hast", + "reportAttachGroup": "Anhänge", + "reportScreenshotRow": "Screenshot hinzufügen", + "reportScreenshotRemove": "Screenshot entfernen", + "reportDatabaseRow": "Gesundheitsdaten der letzten 2 Wochen mitsenden", + "reportDatabaseWarning": "Deine Schlaf-, Herzfrequenz- und anderen Gesundheitsdaten der letzten 14 Tage (weniger Tage sekundengenauer Daten, falls das Ticket sonst zu groß wäre), dazu deine Tageswerte und Einstellungen. Sie werden nur gesendet, wenn dies aktiviert ist, und der Server löscht sie nach 14 Tagen. Ein Ticket mit Gesundheitsdaten kann bis zu 95 MB groß sein, nutze also nach Möglichkeit WLAN.", + "reportIncludedGroup": "Was enthalten ist", + "reportIncludedNote": "Kategorie, Titel, Beschreibung, App-Version, Plattform, Systemversion, Smartphone-Modell, Installations-ID und ob eine Datenbank oder ein Screenshot angehängt ist, werden unverschlüsselt übertragen, damit wir die Meldung zuordnen können, und 90 Tage aufbewahrt. Alles andere wird auf diesem Smartphone verschlüsselt, bevor es das Gerät verlässt, sodass nur die Entwickler es lesen können, und nach 30 Tagen gelöscht, oder nach 14 Tagen, wenn deine Datenbank angehängt ist. „Lokal teilen“ ist anders: Das ZIP, das an das Teilen-Menü übergeben wird, ist nicht verschlüsselt.", + "reportFileManifest": "Diese Dateiliste", + "reportFileMeta": "App-Version, Smartphone-Modell, Systemversion, Sprache, Zeitzone und eine zufällige Installations-ID", + "reportFileDevice": "Dein gekoppeltes Band und deine Sensoren: Typ, Verbindung, Akku und letzte Synchronisierung", + "reportFileState": "Synchronisierungs- und Auswertungsstatus sowie aktuelle Fehler", + "reportFileSyncLog": "Das aktuelle Synchronisierungsprotokoll der App", + "reportFileTelemetry": "Fehler und Ereignisse, die dieses Smartphone in den letzten 7 Tagen aufgezeichnet hat", + "reportFileUser": "Deine Kategorie, dein Titel und deine Beschreibung", + "reportFileScreenshot": "Der ausgewählte Screenshot", + "reportFileDatabase": "Deine vollständige Gesundheitsdatenbank, komprimiert", + "reportFileDatabaseSlice": "Gesundheitsdaten der letzten 14 Tage, dazu Tageswerte und Einstellungen, komprimiert", + "reportFullDatabaseRow": "Vollständige Gesundheitsdatenbank (nur lokal teilen)", + "reportFullDatabaseWarning": "Deine gesamten Gesundheitsdaten. Sie landen nur in der Datei, die „Lokal teilen“ erstellt, niemals auf unserem Server.", + "reportFullDatabaseNoUpload": "Die vollständige Datenbank ist zu groß zum Hochladen. Nutze „Lokal teilen“ und schick uns die Datei.", + "reportDatabaseCovered": "In der vollständigen Datenbank unten enthalten.", + "reportStageSlice": "Letzte 2 Wochen werden vorbereitet…", + "reportSend": "Ticket erstellen", + "reportSaveInstead": "Lokal teilen", + "reportNeedTitle": "Gib zuerst einen Titel ein.", + "reportSentTitle": "Ticket erstellt", + "reportSentBody": "Deine Ticket-ID ist {id}. Gib sie an, wenn du uns dazu kontaktierst.", + "reportCopyId": "ID kopieren", + "reportNotSentTitle": "Ticket nicht erstellt", + "reportTooLarge": "Dieses Ticket ist zu groß zum Hochladen. Nutze „Lokal teilen“ und schick uns die Datei, oder schalte die Gesundheitsdaten aus bzw. entferne den Screenshot.", + "reportRateLimited": "Du hast heute schon 3 Tickets erstellt. Versuch es morgen wieder oder teile dieses lokal.", + "reportStorageFull": "Der Meldeserver hat gerade keinen Platz für eine Datenbank. Du kannst das Ticket ohne sie erstellen.", + "reportSendWithoutDb": "Ticket ohne Datenbank erstellen", + "reportSendFailed": "Das Ticket konnte nicht erstellt werden. Prüfe deine Verbindung oder teile es stattdessen lokal.", + "reportMyReportsGroup": "Meine Meldungen", + "reportMyReportsSub": "Tippen, um sie jetzt zu löschen. Sonst wird sie am {date} gelöscht", + "reportDelete": "Löschen", + "reportDeleteFailed": "Löschen fehlgeschlagen. Versuch es später erneut.", + "reportSaveFailedTitle": "Meldung konnte nicht geteilt werden", + "reportSaveFailedBody": "Beim Erstellen oder Teilen der Datei ist etwas schiefgelaufen. Es wurde kein Ticket erstellt.", + "reportSaveRetry": "Erneut versuchen", + "reportStageCollecting": "App-Infos werden gesammelt…", + "reportStageLogs": "Protokolle werden gelesen…", + "reportStageDatabase": "Datenbank wird komprimiert…", + "reportStagePacking": "Dateien werden gepackt…", + "reportStageEncrypting": "Wird verschlüsselt…", + "reportStageUploading": "{size} MB werden hochgeladen…", + "reportStageSharing": "Teilen wird vorbereitet…", + "reportIdCopied": "{id} kopiert", "reportDeleteConfirmTitle": "Bericht {id} löschen?", "reportDeleteConfirmBody": "Er wird sofort vom Server entfernt und lässt sich danach nicht mehr über seine ID öffnen. Das lässt sich nicht rückgängig machen.", "profileImplausibleFields": "Bitte {fields} prüfen. Der Wert liegt außerhalb des gültigen Bereichs, daher wurde nichts gespeichert.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7c7e7f597..60dec392b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1964,7 +1964,6 @@ "activitySummaryTotalVolume": "Volumen total", "activitySummaryElapsedTime": "Tiempo transcurrido", "activitySummaryClimbed": "+{m} m de ascenso", - "activitySummaryClimbedFt": "+{ft} ft de ascenso", "activitySummaryLapsCaption": "{n, plural, one{{n} largo} other{{n} largos}}", "activitySummaryNoRouteTitle": "No hay ruta para esta sesión", "activitySummaryNoRouteBody": "La ubicación estaba desactivada, o esta actividad no se registró con GPS.", @@ -2705,6 +2704,7 @@ "journalComposeAddToJournal": "Añadir al diario", "workoutLoadFailedTitle": "No se pudo leer la actividad", "workoutLoadFailedBody": "Tus sesiones no se pudieron cargar. No se ha borrado nada: es una lectura que falló, no datos que falten.", + "activitySummaryClimbedFt": "+{ft} ft de ascenso", "liveHrNoReadingTitle": "Sin lectura en directo", "liveHrNotPairedWhy": "No hay ninguna banda emparejada.", "liveHrNotPairedFix": "Empareja una desde Perfil para leer latidos en directo.", @@ -2717,6 +2717,87 @@ "notifRecoveryReadySleptBody": "Recuperación {score}, dormiste {hours} h {minutes} min.", "notifStepGoalBody": "Llegaste a unos {steps} pasos, al menos tu objetivo de {goal}.", "notifLowBatteryBody": "Tu banda está al {percent} %. Cárgala pronto.", + "settingsZoneAlertRowTitle": "Aviso de zona de FC", + "settingsZoneAlertRowSub": "Vibra cuando tu frecuencia cardíaca entra o sale de la zona objetivo durante un entrenamiento en vivo", + "settingsZoneAlertTargetRowTitle": "Zona objetivo", + "settingsZoneAlertTargetRowValue": "Zona {zone}", + "settingsAlarmLatchFailedRowTitle": "Alarma no confirmada", + "settingsAlarmLatchFailedRowSub": "Avisa cuando la banda no confirma una alarma que la app acaba de activar", + "settingsAlarmNightCheckRowTitle": "Comprobación sin alarma", + "settingsAlarmNightCheckRowSub": "Un aviso a las 19:00 cualquier noche sin alarma activada; si no, nada", + "supplementUnreadableFields": "«{fields}» no son números. No se guardó nada.", + "presentationHeightCm": "Altura (cm)", + "presentationHeightIn": "Altura (in)", + "presentationWeightLb": "Peso (lb)", + "supplementUnreadableField": "«{field}» no es un número. No se guardó nada.", + "settingsGroupApp": "App", + "settingsGroupData": "Datos", + "settingsGroupAdvanced": "Avanzado", + "settingsCycleTrackingRowSubHealth": "Agrega Ciclo a Salud. Desactivado lo oculta y conserva todo lo ya registrado", + "profileSetupCycleSub": "Registra tu periodo en Salud. Puedes cambiarlo más tarde.", + "settingsReportIssueRowTitle": "Informar de un problema", + "settingsReportIssueRowSub": "Envía lo que elijas a los responsables, cifrado", + "reportNavTitle": "Informar de un problema", + "reportCategoryGroup": "De qué se trata", + "reportCategorySync": "Sincronización", + "reportCategorySleep": "Sueño", + "reportCategoryRecovery": "Recuperación", + "reportCategoryWorkout": "Entrenamiento", + "reportCategoryPairing": "Emparejamiento", + "reportCategoryOther": "Otro", + "reportTitleLabel": "Título", + "reportTitleHint": "Una línea sobre qué salió mal", + "reportDescriptionLabel": "Qué pasó", + "reportDescriptionHint": "Qué hiciste, qué esperabas, qué viste", + "reportAttachGroup": "Adjuntos", + "reportScreenshotRow": "Añadir una captura de pantalla", + "reportScreenshotRemove": "Quitar la captura de pantalla", + "reportDatabaseRow": "Incluir los datos de salud de las últimas 2 semanas", + "reportDatabaseWarning": "Tus datos de sueño, frecuencia cardíaca y otros datos de salud de los últimos 14 días (menos días de datos segundo a segundo si el ticket fuera demasiado grande), además de tus puntuaciones diarias y ajustes. Solo se envían si esto está activado, y el servidor los borra a los 14 días. Un ticket con datos de salud puede ocupar hasta 95 MB, así que usa wifi si puedes.", + "reportIncludedGroup": "Qué se incluye", + "reportIncludedNote": "La categoría, el título, la descripción, la versión de la app, la plataforma, la versión del sistema, el modelo de teléfono, el ID de instalación y si hay una base de datos o captura adjunta viajan sin cifrar para poder hacer seguimiento del informe, y se guardan 90 días. Todo lo demás se cifra en este teléfono antes de salir, así que solo los responsables pueden leerlo, y se borra a los 30 días, o a los 14 si adjuntas tu base de datos. «Compartir localmente» es distinto: el ZIP que pasa al menú de compartir no está cifrado.", + "reportFileManifest": "Esta lista de archivos", + "reportFileMeta": "Versión de la app, modelo de teléfono, versión del sistema, idioma, zona horaria y un ID de instalación aleatorio", + "reportFileDevice": "Tu banda y sensores emparejados: tipo, conexión, batería y última sincronización", + "reportFileState": "Estado de sincronización y de cálculo, y errores recientes", + "reportFileSyncLog": "El registro de sincronización reciente de la app", + "reportFileTelemetry": "Errores y eventos que este teléfono registró en los últimos 7 días", + "reportFileUser": "Tu categoría, título y descripción", + "reportFileScreenshot": "La captura de pantalla que elegiste", + "reportFileDatabase": "Tu base de datos de salud completa, comprimida", + "reportFileDatabaseSlice": "Datos de salud de los últimos 14 días, más puntuaciones diarias y ajustes, comprimidos", + "reportFullDatabaseRow": "Base de datos de salud completa (solo compartir localmente)", + "reportFullDatabaseWarning": "Todo tu historial de salud. Solo va en el archivo que crea «Compartir localmente», nunca a nuestro servidor.", + "reportFullDatabaseNoUpload": "La base de datos completa es demasiado grande para subirla. Usa «Compartir localmente» y envíanos el archivo.", + "reportDatabaseCovered": "Incluido en la base de datos completa de abajo.", + "reportStageSlice": "Preparando las últimas 2 semanas…", + "reportSend": "Crear ticket", + "reportSaveInstead": "Compartir localmente", + "reportNeedTitle": "Añade primero un título.", + "reportSentTitle": "Ticket creado", + "reportSentBody": "El ID de tu ticket es {id}. Indícalo si nos escribes sobre esto.", + "reportCopyId": "Copiar ID", + "reportNotSentTitle": "Ticket no creado", + "reportTooLarge": "Este ticket es demasiado grande para subirlo. Usa «Compartir localmente» y envíanos el archivo, o desactiva los datos de salud o quita la captura de pantalla.", + "reportRateLimited": "Hoy ya creaste 3 tickets. Vuelve a intentarlo mañana o comparte este localmente.", + "reportStorageFull": "El servidor de informes no tiene espacio para una base de datos ahora mismo. Puedes crear el ticket sin ella.", + "reportSendWithoutDb": "Crear ticket sin la base de datos", + "reportSendFailed": "No se pudo crear el ticket. Revisa tu conexión o compártelo localmente.", + "reportMyReportsGroup": "Mis informes", + "reportMyReportsSub": "Toca para borrarlo ahora. Si no, se borrará el {date}", + "reportDelete": "Borrar", + "reportDeleteFailed": "No se pudo borrar. Inténtalo más tarde.", + "reportSaveFailedTitle": "No se pudo compartir el informe", + "reportSaveFailedBody": "Algo falló al crear o compartir el archivo. No se creó ningún ticket.", + "reportSaveRetry": "Reintentar", + "reportStageCollecting": "Recopilando información de la app…", + "reportStageLogs": "Leyendo registros…", + "reportStageDatabase": "Comprimiendo la base de datos…", + "reportStagePacking": "Empaquetando archivos…", + "reportStageEncrypting": "Cifrando…", + "reportStageUploading": "Subiendo {size} MB…", + "reportStageSharing": "Preparando para compartir…", + "reportIdCopied": "{id} copiado", "reportDeleteConfirmTitle": "¿Eliminar el informe {id}?", "reportDeleteConfirmBody": "Se borra del servidor ahora mismo y ya no se podrá abrir con su ID. No se puede deshacer.", "profileImplausibleFields": "Revisa {fields}. El valor está fuera de rango, así que no se guardó nada.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index dc38685a8..0f86cd090 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1952,7 +1952,6 @@ "activitySummaryTotalVolume": "Volume total", "activitySummaryElapsedTime": "Temps écoulé", "activitySummaryClimbed": "+{m} m de dénivelé", - "activitySummaryClimbedFt": "+{ft} pi de dénivelé", "activitySummaryLapsCaption": "{n, plural, one{{n} longueur} other{{n} longueurs}}", "activitySummaryNoRouteTitle": "Aucun itinéraire pour cette séance", "activitySummaryNoRouteBody": "La localisation était désactivée, ou cette activité n'a pas été enregistrée avec le GPS.", @@ -2676,6 +2675,7 @@ "journalComposeAddToJournal": "Ajouter au journal", "workoutLoadFailedTitle": "Impossible de lire l'activité", "workoutLoadFailedBody": "Vos séances n'ont pas pu être chargées. Rien n'a été supprimé : c'est une lecture qui a échoué, pas des données manquantes.", + "activitySummaryClimbedFt": "+{ft} pi de dénivelé", "liveHrNoReadingTitle": "Aucune mesure en direct", "liveHrNotPairedWhy": "Aucun bracelet n'est appairé.", "liveHrNotPairedFix": "Appairez-en un depuis le Profil pour lire les battements en direct.", @@ -2688,6 +2688,87 @@ "notifRecoveryReadySleptBody": "Récupération {score}, sommeil {hours} h {minutes} min.", "notifStepGoalBody": "Vous avez fait environ {steps} pas, au moins votre objectif de {goal}.", "notifLowBatteryBody": "Votre bracelet est à {percent} %. Rechargez-le bientôt.", + "settingsZoneAlertRowTitle": "Alerte de zone cardiaque", + "settingsZoneAlertRowSub": "Vibre quand votre fréquence cardiaque entre dans la zone cible ou en sort pendant un entraînement en direct", + "settingsZoneAlertTargetRowTitle": "Zone cible", + "settingsZoneAlertTargetRowValue": "Zone {zone}", + "settingsAlarmLatchFailedRowTitle": "Alarme non confirmée", + "settingsAlarmLatchFailedRowSub": "Prévient quand le bracelet ne confirme pas une alarme que l’app vient de programmer", + "settingsAlarmNightCheckRowTitle": "Vérification sans alarme", + "settingsAlarmNightCheckRowSub": "Un rappel à 19 h les soirs sans alarme de réveil programmée ; rien sinon", + "supplementUnreadableFields": "« {fields} » ne sont pas des nombres. Rien n’a été enregistré.", + "presentationHeightCm": "Taille (cm)", + "presentationHeightIn": "Taille (in)", + "presentationWeightLb": "Poids (lb)", + "supplementUnreadableField": "« {field} » n’est pas un nombre. Rien n’a été enregistré.", + "settingsGroupApp": "App", + "settingsGroupData": "Données", + "settingsGroupAdvanced": "Avancé", + "settingsCycleTrackingRowSubHealth": "Ajoute Cycle à Santé. Désactivé le masque et conserve tout ce qui est déjà enregistré", + "profileSetupCycleSub": "Enregistrez vos règles dans Santé. Vous pourrez changer cela plus tard.", + "settingsReportIssueRowTitle": "Signaler un problème", + "settingsReportIssueRowSub": "Envoie ce que vous choisissez aux mainteneurs, chiffré", + "reportNavTitle": "Signaler un problème", + "reportCategoryGroup": "Sujet", + "reportCategorySync": "Synchronisation", + "reportCategorySleep": "Sommeil", + "reportCategoryRecovery": "Récupération", + "reportCategoryWorkout": "Entraînement", + "reportCategoryPairing": "Appairage", + "reportCategoryOther": "Autre", + "reportTitleLabel": "Titre", + "reportTitleHint": "Une ligne sur ce qui n’a pas marché", + "reportDescriptionLabel": "Ce qui s’est passé", + "reportDescriptionHint": "Ce que vous avez fait, ce que vous attendiez, ce que vous avez vu", + "reportAttachGroup": "Pièces jointes", + "reportScreenshotRow": "Ajouter une capture d’écran", + "reportScreenshotRemove": "Retirer la capture d’écran", + "reportDatabaseRow": "Inclure les données de santé des 2 dernières semaines", + "reportDatabaseWarning": "Vos données de sommeil, de fréquence cardiaque et autres données de santé des 14 derniers jours (moins de jours de données seconde par seconde si le ticket était trop lourd), ainsi que vos scores quotidiens et réglages. Elles ne partent que si cette option est activée, et le serveur les supprime au bout de 14 jours. Un ticket avec données de santé peut atteindre 95 Mo, utilisez donc le Wi-Fi si possible.", + "reportIncludedGroup": "Ce qui est inclus", + "reportIncludedNote": "La catégorie, le titre, la description, la version de l’app, la plateforme, la version du système, le modèle de téléphone, l’identifiant d’installation et la présence d’une base de données ou d’une capture jointe circulent en clair pour que nous puissions suivre le signalement, et sont conservés 90 jours. Tout le reste est chiffré sur ce téléphone avant de partir, de sorte que seuls les mainteneurs peuvent le lire, et est supprimé au bout de 30 jours, ou de 14 jours si votre base de données est jointe. « Partager localement » est différent : le ZIP transmis au menu de partage n’est pas chiffré.", + "reportFileManifest": "Cette liste de fichiers", + "reportFileMeta": "Version de l’app, modèle de téléphone, version du système, langue, fuseau horaire et un identifiant d’installation aléatoire", + "reportFileDevice": "Votre bracelet et vos capteurs appairés : type, connexion, batterie et dernière synchronisation", + "reportFileState": "État de la synchronisation et du calcul des scores, et erreurs récentes", + "reportFileSyncLog": "Le journal de synchronisation récent de l’app", + "reportFileTelemetry": "Erreurs et événements enregistrés par ce téléphone ces 7 derniers jours", + "reportFileUser": "Votre catégorie, titre et description", + "reportFileScreenshot": "La capture d’écran choisie", + "reportFileDatabase": "Votre base de données de santé complète, compressée", + "reportFileDatabaseSlice": "Données de santé des 14 derniers jours, plus scores quotidiens et réglages, compressées", + "reportFullDatabaseRow": "Base de données de santé complète (partage local uniquement)", + "reportFullDatabaseWarning": "Tout votre historique de santé. Il n’entre que dans le fichier créé par « Partager localement », jamais sur notre serveur.", + "reportFullDatabaseNoUpload": "La base de données complète est trop lourde pour être envoyée. Utilisez « Partager localement » et envoyez-nous le fichier.", + "reportDatabaseCovered": "Inclus dans la base de données complète ci-dessous.", + "reportStageSlice": "Préparation des 2 dernières semaines…", + "reportSend": "Créer le ticket", + "reportSaveInstead": "Partager localement", + "reportNeedTitle": "Ajoutez d’abord un titre.", + "reportSentTitle": "Ticket créé", + "reportSentBody": "L’identifiant de votre ticket est {id}. Indiquez-le si vous nous contactez à ce sujet.", + "reportCopyId": "Copier l’identifiant", + "reportNotSentTitle": "Ticket non créé", + "reportTooLarge": "Ce ticket est trop lourd pour être envoyé. Utilisez « Partager localement » et envoyez-nous le fichier, ou désactivez les données de santé ou retirez la capture d’écran.", + "reportRateLimited": "Vous avez déjà créé 3 tickets aujourd’hui. Réessayez demain, ou partagez celui-ci localement.", + "reportStorageFull": "Le serveur de signalement n’a pas de place pour une base de données pour l’instant. Vous pouvez créer le ticket sans elle.", + "reportSendWithoutDb": "Créer le ticket sans la base de données", + "reportSendFailed": "Impossible de créer le ticket. Vérifiez votre connexion, ou partagez-le localement.", + "reportMyReportsGroup": "Mes signalements", + "reportMyReportsSub": "Touchez pour le supprimer maintenant. Sinon, il sera supprimé le {date}", + "reportDelete": "Supprimer", + "reportDeleteFailed": "Suppression impossible. Réessayez plus tard.", + "reportSaveFailedTitle": "Impossible de partager le signalement", + "reportSaveFailedBody": "Un problème est survenu lors de la création ou du partage du fichier. Aucun ticket n’a été créé.", + "reportSaveRetry": "Réessayer", + "reportStageCollecting": "Collecte des infos de l’app…", + "reportStageLogs": "Lecture des journaux…", + "reportStageDatabase": "Compression de la base de données…", + "reportStagePacking": "Assemblage des fichiers…", + "reportStageEncrypting": "Chiffrement…", + "reportStageUploading": "Envoi de {size} Mo…", + "reportStageSharing": "Préparation du partage…", + "reportIdCopied": "{id} copié", "reportDeleteConfirmTitle": "Supprimer le rapport {id} ?", "reportDeleteConfirmBody": "Il est retiré du serveur tout de suite et ne pourra plus être ouvert avec son identifiant. Impossible d'annuler.", "profileImplausibleFields": "Vérifiez {fields}. La valeur est hors limites, rien n'a donc été enregistré.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index db789740d..ac367773d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1952,7 +1952,6 @@ "activitySummaryTotalVolume": "कुल आयतन", "activitySummaryElapsedTime": "बीता हुआ समय", "activitySummaryClimbed": "+{m} मी चढ़ाई", - "activitySummaryClimbedFt": "+{ft} फ़ुट चढ़ाई", "activitySummaryLapsCaption": "{n, plural, one{{n} चक्कर} other{{n} चक्कर}}", "activitySummaryNoRouteTitle": "इस सत्र के लिए कोई मार्ग नहीं है", "activitySummaryNoRouteBody": "लोकेशन बंद थी, या यह गतिविधि GPS के साथ दर्ज नहीं की गई।", @@ -2676,6 +2675,7 @@ "journalComposeAddToJournal": "जर्नल में जोड़ें", "workoutLoadFailedTitle": "गतिविधि पढ़ी नहीं जा सकी", "workoutLoadFailedBody": "आपके सत्र लोड नहीं हो सके। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", + "activitySummaryClimbedFt": "+{ft} फ़ुट चढ़ाई", "liveHrNoReadingTitle": "कोई लाइव रीडिंग नहीं", "liveHrNotPairedWhy": "कोई बैंड पेयर नहीं है।", "liveHrNotPairedFix": "लाइव धड़कनें पढ़ने के लिए प्रोफ़ाइल से एक बैंड पेयर करें।", @@ -2688,6 +2688,87 @@ "notifRecoveryReadySleptBody": "रिकवरी {score}, नींद {hours} घं {minutes} मि.", "notifStepGoalBody": "आपने लगभग {steps} कदम पूरे किए — आपके {goal} के लक्ष्य जितने या उससे ज़्यादा।", "notifLowBatteryBody": "आपका बैंड {percent}% पर है। जल्द चार्ज करें।", + "settingsZoneAlertRowTitle": "HR ज़ोन अलर्ट", + "settingsZoneAlertRowSub": "लाइव वर्कआउट के दौरान हृदय गति लक्ष्य ज़ोन में आने या उससे बाहर जाने पर वाइब्रेट करता है", + "settingsZoneAlertTargetRowTitle": "लक्ष्य ज़ोन", + "settingsZoneAlertTargetRowValue": "ज़ोन {zone}", + "settingsAlarmLatchFailedRowTitle": "अलार्म की पुष्टि नहीं हुई", + "settingsAlarmLatchFailedRowSub": "जब बैंड ऐप द्वारा अभी सेट किए गए अलार्म की पुष्टि न करे, तब चेतावनी देता है", + "settingsAlarmNightCheckRowTitle": "बिना अलार्म वाली रात की जाँच", + "settingsAlarmNightCheckRowSub": "जिस रात कोई वेक अलार्म सेट न हो, उस रात शाम 7 बजे सूचना — वरना कुछ नहीं", + "supplementUnreadableFields": "{fields} संख्याएँ नहीं हैं। कुछ भी सेव नहीं हुआ।", + "presentationHeightCm": "लंबाई (सेमी)", + "presentationHeightIn": "लंबाई (इंच)", + "presentationWeightLb": "वज़न (पाउंड)", + "supplementUnreadableField": "{field} एक संख्या नहीं है। कुछ भी सेव नहीं हुआ।", + "settingsGroupApp": "ऐप", + "settingsGroupData": "डेटा", + "settingsGroupAdvanced": "उन्नत", + "settingsCycleTrackingRowSubHealth": "स्वास्थ्य में साइकिल जोड़ता है। बंद करने पर यह छिप जाता है और पहले से लॉग किया गया सब कुछ बना रहता है", + "profileSetupCycleSub": "अपना पीरियड स्वास्थ्य में लॉग करें। इसे आप बाद में बदल सकते हैं।", + "settingsReportIssueRowTitle": "समस्या की रिपोर्ट करें", + "settingsReportIssueRowSub": "आप जो चुनें उसे एन्क्रिप्ट करके मेंटेनर्स को भेजता है", + "reportNavTitle": "समस्या की रिपोर्ट करें", + "reportCategoryGroup": "यह किस बारे में है", + "reportCategorySync": "सिंक", + "reportCategorySleep": "नींद", + "reportCategoryRecovery": "रिकवरी", + "reportCategoryWorkout": "वर्कआउट", + "reportCategoryPairing": "पेयरिंग", + "reportCategoryOther": "अन्य", + "reportTitleLabel": "शीर्षक", + "reportTitleHint": "एक पंक्ति में बताएँ कि क्या गलत हुआ", + "reportDescriptionLabel": "क्या हुआ", + "reportDescriptionHint": "आपने क्या किया, क्या उम्मीद थी, क्या दिखा", + "reportAttachGroup": "अटैचमेंट", + "reportScreenshotRow": "स्क्रीनशॉट जोड़ें", + "reportScreenshotRemove": "स्क्रीनशॉट हटाएँ", + "reportDatabaseRow": "पिछले 2 हफ़्तों का स्वास्थ्य डेटा शामिल करें", + "reportDatabaseWarning": "पिछले 14 दिनों का आपकी नींद, हृदय गति और अन्य स्वास्थ्य डेटा (अगर टिकट बहुत बड़ा हो जाए तो सेकंड-दर-सेकंड डेटा कम दिनों का), साथ में आपके दैनिक स्कोर और सेटिंग्स। यह तभी भेजा जाता है जब यह विकल्प चालू हो, और सर्वर इसे 14 दिनों बाद मिटा देता है। स्वास्थ्य डेटा वाला टिकट 95 MB तक का हो सकता है, इसलिए हो सके तो Wi-Fi इस्तेमाल करें।", + "reportIncludedGroup": "क्या शामिल है", + "reportIncludedNote": "श्रेणी, शीर्षक, विवरण, ऐप वर्ज़न, प्लेटफ़ॉर्म, सिस्टम वर्ज़न, फ़ोन मॉडल, इंस्टॉल ID, और डेटाबेस या स्क्रीनशॉट अटैच है या नहीं — ये बिना एन्क्रिप्शन के जाते हैं ताकि रिपोर्ट को ट्रैक किया जा सके, और 90 दिनों तक रखे जाते हैं। बाकी सब कुछ भेजे जाने से पहले इसी फ़ोन पर एन्क्रिप्ट होता है, ताकि केवल मेंटेनर्स ही इसे पढ़ सकें, और 30 दिनों बाद मिटा दिया जाता है, या डेटाबेस अटैच होने पर 14 दिनों बाद। “लोकल शेयर करें” अलग है: शेयर शीट को दी जाने वाली ZIP एन्क्रिप्टेड नहीं होती।", + "reportFileManifest": "फ़ाइलों की यह सूची", + "reportFileMeta": "ऐप वर्ज़न, फ़ोन मॉडल, सिस्टम वर्ज़न, भाषा, टाइम ज़ोन और एक रैंडम इंस्टॉल ID", + "reportFileDevice": "आपका पेयर किया गया बैंड और सेंसर: प्रकार, कनेक्शन, बैटरी और आखिरी सिंक", + "reportFileState": "सिंक और स्कोरिंग की स्थिति, और हाल की त्रुटियाँ", + "reportFileSyncLog": "ऐप का हाल का सिंक लॉग", + "reportFileTelemetry": "पिछले 7 दिनों में इस फ़ोन द्वारा दर्ज त्रुटियाँ और इवेंट", + "reportFileUser": "आपकी श्रेणी, शीर्षक और विवरण", + "reportFileScreenshot": "आपका चुना हुआ स्क्रीनशॉट", + "reportFileDatabase": "आपका पूरा स्वास्थ्य डेटाबेस, कंप्रेस किया हुआ", + "reportFileDatabaseSlice": "पिछले 14 दिनों का स्वास्थ्य डेटा, साथ में दैनिक स्कोर और सेटिंग्स, कंप्रेस किया हुआ", + "reportFullDatabaseRow": "पूरा स्वास्थ्य डेटाबेस (केवल लोकल शेयर)", + "reportFullDatabaseWarning": "आपका पूरा स्वास्थ्य रिकॉर्ड। यह केवल “लोकल शेयर करें” से बनी फ़ाइल में जाता है, हमारे सर्वर पर कभी नहीं।", + "reportFullDatabaseNoUpload": "पूरा डेटाबेस अपलोड करने के लिए बहुत बड़ा है। “लोकल शेयर करें” इस्तेमाल करें और फ़ाइल हमें भेजें।", + "reportDatabaseCovered": "नीचे दिए पूरे डेटाबेस में शामिल है।", + "reportStageSlice": "पिछले 2 हफ़्ते तैयार हो रहे हैं…", + "reportSend": "टिकट बनाएँ", + "reportSaveInstead": "लोकल शेयर करें", + "reportNeedTitle": "पहले एक शीर्षक जोड़ें।", + "reportSentTitle": "टिकट बन गया", + "reportSentBody": "आपकी टिकट ID {id} है। इस बारे में हमसे बात करते समय इसका ज़िक्र करें।", + "reportCopyId": "ID कॉपी करें", + "reportNotSentTitle": "टिकट नहीं बना", + "reportTooLarge": "यह टिकट अपलोड करने के लिए बहुत बड़ा है। “लोकल शेयर करें” इस्तेमाल करें और फ़ाइल हमें भेजें, या स्वास्थ्य डेटा बंद करें या स्क्रीनशॉट हटाएँ।", + "reportRateLimited": "आज आप 3 टिकट बना चुके हैं। कल फिर कोशिश करें, या इसे लोकल शेयर करें।", + "reportStorageFull": "रिपोर्ट सर्वर में अभी डेटाबेस के लिए जगह नहीं है। आप इसके बिना टिकट बना सकते हैं।", + "reportSendWithoutDb": "डेटाबेस के बिना टिकट बनाएँ", + "reportSendFailed": "टिकट नहीं बन सका। अपना कनेक्शन जाँचें, या इसे लोकल शेयर करें।", + "reportMyReportsGroup": "मेरी रिपोर्ट्स", + "reportMyReportsSub": "अभी मिटाने के लिए टैप करें। वरना यह {date} को मिटा दी जाएगी", + "reportDelete": "मिटाएँ", + "reportDeleteFailed": "मिटाया नहीं जा सका। बाद में फिर कोशिश करें।", + "reportSaveFailedTitle": "रिपोर्ट शेयर नहीं हो सकी", + "reportSaveFailedBody": "फ़ाइल बनाते या शेयर करते समय कुछ गलत हो गया। कोई टिकट नहीं बना।", + "reportSaveRetry": "फिर कोशिश करें", + "reportStageCollecting": "ऐप की जानकारी जुटाई जा रही है…", + "reportStageLogs": "लॉग पढ़े जा रहे हैं…", + "reportStageDatabase": "डेटाबेस कंप्रेस हो रहा है…", + "reportStagePacking": "फ़ाइलें पैक हो रही हैं…", + "reportStageEncrypting": "एन्क्रिप्ट हो रहा है…", + "reportStageUploading": "{size} MB अपलोड हो रहा है…", + "reportStageSharing": "शेयर करने की तैयारी…", + "reportIdCopied": "{id} कॉपी हो गया", "reportDeleteConfirmTitle": "रिपोर्ट {id} हटाएँ?", "reportDeleteConfirmBody": "यह अभी सर्वर से हट जाएगी और इसके ID से फिर नहीं खुलेगी। इसे वापस नहीं लाया जा सकता।", "profileImplausibleFields": "{fields} जाँचें। मान सीमा से बाहर है, इसलिए कुछ भी सहेजा नहीं गया।", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 205f90eb7..0028a08ea 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1952,7 +1952,6 @@ "activitySummaryTotalVolume": "总训练量", "activitySummaryElapsedTime": "已用时间", "activitySummaryClimbed": "+{m} 米爬升", - "activitySummaryClimbedFt": "+{ft} 英尺爬升", "activitySummaryLapsCaption": "{n, plural, other{{n} 趟}}", "activitySummaryNoRouteTitle": "此次记录没有路线", "activitySummaryNoRouteBody": "定位功能未开启,或此次活动未使用 GPS 记录。", @@ -2676,6 +2675,7 @@ "journalComposeAddToJournal": "添加到日志", "workoutLoadFailedTitle": "无法读取活动", "workoutLoadFailedBody": "你的训练记录加载失败。没有删除任何内容——这是读取出错,不是数据缺失。", + "activitySummaryClimbedFt": "+{ft} 英尺爬升", "liveHrNoReadingTitle": "无实时读数", "liveHrNotPairedWhy": "尚未配对手环。", "liveHrNotPairedFix": "在“个人资料”中配对手环以读取实时心跳。", @@ -2688,6 +2688,87 @@ "notifRecoveryReadySleptBody": "恢复 {score},睡眠 {hours} 小时 {minutes} 分钟。", "notifStepGoalBody": "你走了约 {steps} 步,达到或超过 {goal} 步的目标。", "notifLowBatteryBody": "手环电量 {percent}%。请尽快充电。", + "settingsZoneAlertRowTitle": "心率区间提醒", + "settingsZoneAlertRowSub": "实时锻炼中心率进入或离开目标区间时振动", + "settingsZoneAlertTargetRowTitle": "目标区间", + "settingsZoneAlertTargetRowValue": "区间 {zone}", + "settingsAlarmLatchFailedRowTitle": "闹钟未确认", + "settingsAlarmLatchFailedRowSub": "手环未确认本应用刚设置的闹钟时提醒", + "settingsAlarmNightCheckRowTitle": "无闹钟提醒", + "settingsAlarmNightCheckRowSub": "当晚未设置起床闹钟时,晚上 7 点提醒一次;否则不打扰", + "supplementUnreadableFields": "“{fields}”不是数字。未保存任何内容。", + "presentationHeightCm": "身高(厘米)", + "presentationHeightIn": "身高(英寸)", + "presentationWeightLb": "体重(磅)", + "supplementUnreadableField": "“{field}”不是数字。未保存任何内容。", + "settingsGroupApp": "应用", + "settingsGroupData": "数据", + "settingsGroupAdvanced": "高级", + "settingsCycleTrackingRowSubHealth": "在“健康”中添加“周期”。关闭后会隐藏,但已记录的内容仍会保留", + "profileSetupCycleSub": "在“健康”中记录经期。之后可以更改。", + "settingsReportIssueRowTitle": "报告问题", + "settingsReportIssueRowSub": "将你选择的内容加密发送给维护者", + "reportNavTitle": "报告问题", + "reportCategoryGroup": "问题类型", + "reportCategorySync": "同步", + "reportCategorySleep": "睡眠", + "reportCategoryRecovery": "恢复", + "reportCategoryWorkout": "锻炼", + "reportCategoryPairing": "配对", + "reportCategoryOther": "其他", + "reportTitleLabel": "标题", + "reportTitleHint": "用一句话说明出了什么问题", + "reportDescriptionLabel": "发生了什么", + "reportDescriptionHint": "你做了什么、预期是什么、看到了什么", + "reportAttachGroup": "附件", + "reportScreenshotRow": "添加截图", + "reportScreenshotRemove": "移除截图", + "reportDatabaseRow": "附上最近 2 周的健康数据", + "reportDatabaseWarning": "最近 14 天的睡眠、心率和其他健康数据(如果工单过大,逐秒数据的天数会减少),以及每日分数和设置。仅在开启此项时发送,服务器会在 14 天后删除。附带健康数据的工单最大可达 95 MB,请尽量使用 Wi-Fi。", + "reportIncludedGroup": "包含内容", + "reportIncludedNote": "类别、标题、描述、应用版本、平台、系统版本、手机型号、安装 ID 以及是否附带数据库或截图会以未加密方式传输,以便我们跟踪报告,并保留 90 天。其他所有内容在离开本机前都会在这部手机上加密,只有维护者能读取,并在 30 天后删除;如果附带了数据库,则在 14 天后删除。“本地分享”不同:交给分享菜单的 ZIP 未加密。", + "reportFileManifest": "此文件列表", + "reportFileMeta": "应用版本、手机型号、系统版本、语言、时区和一个随机安装 ID", + "reportFileDevice": "已配对的手环和传感器:类型、连接、电量和上次同步时间", + "reportFileState": "同步和评分状态,以及最近的错误", + "reportFileSyncLog": "应用最近的同步日志", + "reportFileTelemetry": "这部手机最近 7 天记录的错误和事件", + "reportFileUser": "你填写的类别、标题和描述", + "reportFileScreenshot": "你选择的截图", + "reportFileDatabase": "完整的健康数据库(已压缩)", + "reportFileDatabaseSlice": "最近 14 天的健康数据,以及每日分数和设置(已压缩)", + "reportFullDatabaseRow": "完整健康数据库(仅限本地分享)", + "reportFullDatabaseWarning": "你的全部健康记录。它只会放进“本地分享”生成的文件中,绝不会发送到我们的服务器。", + "reportFullDatabaseNoUpload": "完整数据库太大,无法上传。请使用“本地分享”,然后把文件发给我们。", + "reportDatabaseCovered": "已包含在下方的完整数据库中。", + "reportStageSlice": "正在准备最近 2 周的数据…", + "reportSend": "创建工单", + "reportSaveInstead": "本地分享", + "reportNeedTitle": "请先填写标题。", + "reportSentTitle": "工单已创建", + "reportSentBody": "你的工单 ID 是 {id}。就此事联系我们时请提供。", + "reportCopyId": "复制 ID", + "reportNotSentTitle": "工单未创建", + "reportTooLarge": "此工单太大,无法上传。请使用“本地分享”并把文件发给我们,或关闭健康数据、移除截图。", + "reportRateLimited": "你今天已创建 3 个工单。请明天再试,或改为本地分享。", + "reportStorageFull": "报告服务器目前没有空间存放数据库。你可以不附带数据库创建工单。", + "reportSendWithoutDb": "不附带数据库创建工单", + "reportSendFailed": "无法创建工单。请检查网络连接,或改为本地分享。", + "reportMyReportsGroup": "我的报告", + "reportMyReportsSub": "点按立即删除。否则将于 {date} 删除", + "reportDelete": "删除", + "reportDeleteFailed": "无法删除。请稍后再试。", + "reportSaveFailedTitle": "无法分享报告", + "reportSaveFailedBody": "生成或分享文件时出错。未创建工单。", + "reportSaveRetry": "重试", + "reportStageCollecting": "正在收集应用信息…", + "reportStageLogs": "正在读取日志…", + "reportStageDatabase": "正在压缩数据库…", + "reportStagePacking": "正在打包文件…", + "reportStageEncrypting": "正在加密…", + "reportStageUploading": "正在上传 {size} MB…", + "reportStageSharing": "正在准备分享…", + "reportIdCopied": "已复制 {id}", "reportDeleteConfirmTitle": "删除报告 {id}?", "reportDeleteConfirmBody": "它会立即从服务器上删除,之后无法再通过 ID 打开。此操作无法撤销。", "profileImplausibleFields": "请检查{fields}。数值超出范围,因此未保存任何内容。", diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index daa4d29e9..2b5b473bd 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -134,7 +134,8 @@ class _ProfileSetupViewState extends State { /// and dropping it silently is how a body ends up half-described. Future _continue() async { final bad = [ - if (Typed.of(_age.text).bad) 'Age', + if (Typed.of(_age.text).bad) + AppLocalizations.of(context)?.settingsAgeFieldLabel ?? 'Age', if (Typed.of(_height.text).bad) uiText(context, _u.heightLabel), if (Typed.of(_weight.text).bad) uiText(context, _u.weightLabel), ]; diff --git a/test/settings_report_l10n_test.dart b/test/settings_report_l10n_test.dart new file mode 100644 index 000000000..4ea6a3e6f --- /dev/null +++ b/test/settings_report_l10n_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// gen-l10n fills a key a locale lacks from the English template without a +/// warning, so a missing key shows up as English text inside an otherwise +/// translated screen. These are the Settings group headers, the whole +/// Report-an-issue screen and the onboarding strings that were English in +/// every locale except Russian. +void main() { + const locales = ['de', 'es', 'fr', 'hi', 'ru', 'zh']; + + Map arb(String locale) => + jsonDecode(File('lib/l10n/app_$locale.arb').readAsStringSync()) + as Map; + + bool covered(String k) => + k.startsWith('report') || + k.startsWith('settingsGroup') || + k.startsWith('settingsReportIssue') || + k.startsWith('settingsZoneAlert') || + k.startsWith('settingsAlarm') || + k == 'settingsCycleTrackingRowSubHealth' || + k == 'profileSetupCycleSub' || + k == 'supplementUnreadableField' || + k == 'supplementUnreadableFields' || + k == 'presentationHeightCm' || + k == 'presentationHeightIn' || + k == 'presentationWeightLb'; + + Set placeholders(String s) => + RegExp(r'\{(\w+)\}').allMatches(s).map((m) => m[1]!).toSet(); + + final en = arb('en'); + final keys = en.keys.where((k) => !k.startsWith('@') && covered(k)).toList(); + + test('the key set under test is not empty', () { + expect(keys, contains('reportNavTitle')); + expect(keys, contains('settingsGroupApp')); + }); + + for (final locale in locales) { + test('$locale translates settings, report and onboarding strings', () { + final l = arb(locale); + final missing = keys.where((k) => !l.containsKey(k)).toList(); + expect(missing, isEmpty); + for (final k in keys) { + expect( + placeholders(l[k] as String), + placeholders(en[k] as String), + reason: '$locale $k', + ); + } + }); + } +} From 542cbc5830c5601c957e7cdc5b50d68e8503d51b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 19 Oct 2026 16:07:14 +0530 Subject: [PATCH 22/72] past-day recovery and strain rings opened today's screens on a past day home showed that day's recovery and strain, but tapping either ring (or the gap row, or the not-scored card) opened readiness and day-strain detail screens that always read today, so the screen contradicted the ring. both detail screens now take the day, and home passes it the same way it already did for sleep. a past day's recovery shows that day's stored score and its own absence reason, and skips the percentile breakdown, which only exists for today. (cherry picked from commit 639bb46ea3910a69527ef6f775b60fc062a2da94) --- lib/ui2/activity/day_strain.dart | 15 +++++-- lib/ui2/screens/home_screen.dart | 11 +++-- lib/ui2/screens/readiness_detail.dart | 41 ++++++++++++++++-- test/past_day_detail_test.dart | 61 +++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 test/past_day_detail_test.dart diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index aad3180bd..0fdadc85f 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -94,8 +94,12 @@ class DayStrainData { bool get hasCurve => curve.any((v) => v != null); - static Future load(LocalRepository repo) async { - final asked = todayLabel(); + /// [forDay] is the label the caller is looking at; null means today. Home's + /// day switcher passes it, so a past day's strain ring opens that day's + /// curve rather than today's. + static Future load(LocalRepository repo, + {String? forDay}) async { + final asked = forDay ?? todayLabel(); final s = await repo.getDayStrain(asked); if (s.isEmpty) return const DayStrainData(); @@ -169,7 +173,10 @@ class DayStrainData { class DayStrainDetail extends StatefulWidget { /// Preloaded, for goldens. Null means read the repo on open. final DayStrainData? data; - const DayStrainDetail({super.key, this.data}); + + /// The day to read, as a local label. Null means today. + final String? day; + const DayStrainDetail({super.key, this.data, this.day}); @override State createState() => _DayStrainDetailState(); @@ -197,7 +204,7 @@ class _DayStrainDetailState extends State { return; } try { - final d = await DayStrainData.load(repo); + final d = await DayStrainData.load(repo, forDay: widget.day); if (mounted) setState(() => (_d = d, _loading = false)); } catch (_) { if (mounted) setState(() => _loading = false); diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 36ceadb3c..ad863230f 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1959,8 +1959,12 @@ class _HomeScreenState extends State with RevisionReload { onOpen: (k) => go( c, switch (k) { - HomeRingKind.recovery => const ReadinessDetail(), - HomeRingKind.strain => const DayStrainDetail(), + // A past day's rings open that day's screens: the number + // tapped and the number shown must be the same day's. + HomeRingKind.recovery => + ReadinessDetail(day: isToday ? null : _day), + HomeRingKind.strain => + DayStrainDetail(day: isToday ? null : _day), // The night the ring was drawn from, not last night. Today // stays null even after the switcher steps back onto it: // today's ring is the held-over night, and only the default @@ -1996,7 +2000,8 @@ class _HomeScreenState extends State with RevisionReload { (l?.homeReadinessNoReason ?? 'Nothing recorded says why.'), fix: l?.homeSeeWhatWasMissing ?? 'See what was missing', icon: LucideIcons.batteryCharging, - onFix: () => go(c, const ReadinessDetail()), + onFix: () => + go(c, ReadinessDetail(day: isToday ? null : _day)), ); }), diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 9cf9c5184..5b7058b8e 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -58,7 +58,12 @@ class ReadinessData { bool get provisional => head['recovery_state'] == 'provisional'; + /// The past day this was read for, or null for today. Set only by + /// [loadForDay]. + final String? day; + const ReadinessData({ + this.day, this.head = const {}, this.readiness = Metric.empty, this.breakdown = const [], @@ -68,6 +73,25 @@ class ReadinessData { this.absentDiag, }); + /// A past day, as Home's day switcher shows it: the stored score for + /// [date] and that day's own absence diagnostic. No breakdown — the + /// percentile view only exists in today's insights, and borrowing today's + /// would put this morning's ranking under last Tuesday's number. + static Future loadForDay( + LocalRepository repo, String date) async { + final overview = await repo.getDayOverview(date); + final chart = await repo.getChart('recovery'); + final readiness = metricOf(overview['readiness']); + return ReadinessData( + day: date, + readiness: readiness, + series: denseDays(pointsOf(chart), 90), + absentDiag: readiness.value != null + ? null + : await LocalDb.readinessAbsentDiag(date), + ); + } + static Future load(LocalRepository repo) async { final today = await repo.getToday(); final cd = await repo.getInsights(); @@ -120,7 +144,10 @@ class ReadinessData { class ReadinessDetail extends StatefulWidget { final ReadinessData? data; - const ReadinessDetail({super.key, this.data}); + + /// The day to read, as a local label. Null means today. + final String? day; + const ReadinessDetail({super.key, this.data, this.day}); @override State createState() => _ReadinessDetailState(); @@ -151,7 +178,10 @@ class _ReadinessDetailState extends State { return; } try { - final d = await ReadinessData.load(repo); + final day = widget.day; + final d = day == null || day == todayLabel() + ? await ReadinessData.load(repo) + : await ReadinessData.loadForDay(repo, day); if (mounted) setState(() => (_d = d, _loading = false)); } catch (_) { if (mounted) setState(() => _loading = false); @@ -169,7 +199,10 @@ class _ReadinessDetailState extends State { // No date in the nav bar. It named the held-over night, and the headline // can no longer BE that night — a date up here now would be labelling // today's number with somebody else's day. - return detailScaffold(c, l?.readinessDetailTitle ?? 'Recovery', [ + // A day the person stepped back to IS named: the number below is that + // day's, not a held-over stand-in for today. + return detailScaffold(c, l?.readinessDetailTitle ?? 'Recovery', + sub: d.day == null ? '' : prettyDay(d.day, l), [ if (_loading && _d == null) ...[ const SizedBox(height: S.x8), const Center(child: CircularProgressIndicator()), @@ -297,7 +330,7 @@ class _ReadinessDetailState extends State { ), ]), ), - ] else if (v != null) + ] else if (v != null && d.day == null) Section( l?.readinessDetailWhatWentIntoIt ?? 'What went into it', StatusCard( diff --git a/test/past_day_detail_test.dart b/test/past_day_detail_test.dart new file mode 100644 index 000000000..b413311ea --- /dev/null +++ b/test/past_day_detail_test.dart @@ -0,0 +1,61 @@ +// Home's day switcher can step back to a past day, and the rings it draws are +// that day's. Tapping one has to open that day too: the recovery and strain +// detail screens used to read today unconditionally, so a past day's 41 +// opened a screen saying 78. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; +import 'package:openstrap_edge/ui2/screens/readiness_detail.dart'; + +class _Repo extends LocalRepository { + final asked = []; + + @override + Future> getToday() async => + throw StateError('a past day must not read today'); + + @override + Future> getInsights() async => + throw StateError('a past day must not read today\'s insights'); + + @override + Future> getDayOverview(String date) async { + asked.add('overview:$date'); + return { + 'readiness': {'value': date == '2026-05-19' ? 41 : 78, 'confidence': 0.8}, + }; + } + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + const {'points': []}; + + @override + Future> getDayStrain(String date) async { + asked.add('strain:$date'); + return {'curve': const [], 'strain': date == '2026-05-19' ? 15.2 : 3.0}; + } + + @override + Future> getDayWear(String date) async => const {}; +} + +void main() { + test('recovery for a past day is that day\'s stored score', () async { + final r = _Repo(); + final d = await ReadinessData.loadForDay(r, '2026-05-19'); + expect(d.readiness.value, 41); + expect(d.day, '2026-05-19'); + expect(d.breakdown, isEmpty, + reason: 'today\'s percentile ranking is not this day\'s'); + expect(r.asked, ['overview:2026-05-19']); + }); + + test('strain for a past day asks for that day', () async { + final r = _Repo(); + final d = await DayStrainData.load(r, forDay: '2026-05-19'); + expect(d.strain, 15.2); + expect(r.asked, contains('strain:2026-05-19')); + }); +} From 7a2f3978abcdcb2506ad23da8e328201f2c929ee Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:17 +0530 Subject: [PATCH 23/72] sleep tab stayed on an old night after using the day stepper stepping back a night and forward again stored the newest night's literal date, and since the tab is never disposed the next day's sync reloaded onto that same night. the stepper now stores null when it lands on the newest night so the tab keeps following it. health's vitals stepper had the same pattern and now stores null on today, like home does. (cherry picked from commit 98695ea0220d2117f9e3a6b80f55ecd5f4c6cea6) --- lib/ui2/screens/health_screen.dart | 4 +++- lib/ui2/screens/sleep_detail.dart | 12 +++++++++++- test/ui2_sleep_detail_test.dart | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 3a8b01ab5..1652aba29 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -594,7 +594,9 @@ class _HealthScreenState extends State with RevisionReload { } void _goVitalsDay(String day) { - setState(() => _vDay = day); + // Stored as null on today, as Home does: Health is kept alive by the shell + // and a literal date would pin Vitals to it once the calendar moves on. + setState(() => _vDay = homeSwitcherDay(day)); _loadVitals(force: true); } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 1f6a4e67a..b8b6a36c6 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -380,6 +380,16 @@ class SleepData { } } +/// What the Sleep stepper stores for [day]: null when it is the newest night +/// on offer, so the screen keeps following the newest night. The tab lives in +/// the shell's IndexedStack and is never disposed — stepping back a night and +/// forward again stored that night's literal date, and the next morning's +/// sync reloaded onto it, so the tab sat on yesterday's night until the +/// arrows were touched again. +@visibleForTesting +String? sleepStepperDay(String day, String? latestDay) => + day == latestDay ? null : day; + class SleepDetail extends StatefulWidget { final SleepData? data; @@ -571,7 +581,7 @@ class _SleepDetailState extends State with RevisionReload { /// it goes with it. void _goDay(String day) { setState(() { - _day = day; + _day = sleepStepperDay(day, _d?.latestDay); _scrub = null; _overrideFailed = null; _loading = true; diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index b0c036eb5..af999eadc 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -461,6 +461,21 @@ void main() { }); }); + group('sleepStepperDay', () { + test('stepping onto the newest night follows it rather than pinning it', + () { + expect(sleepStepperDay('2026-10-17', '2026-10-17'), isNull); + }); + + test('an older night stays pinned', () { + expect(sleepStepperDay('2026-10-16', '2026-10-17'), '2026-10-16'); + }); + + test('without a known newest night the chosen date is kept', () { + expect(sleepStepperDay('2026-10-17', null), '2026-10-17'); + }); + }); + group('the Sleep tab', () { Future tab(WidgetTester t, String day, {String? latest}) async { t.view.physicalSize = const Size(390 * 3, 5200 * 3); From 0e69ae37ce5d3e8e73fe757dc759267977052c92 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:11:36 +0530 Subject: [PATCH 24/72] home kept showing the old step goal after it was changed changing the goal on the steps screen or through the coach only notified listeners, but home re-reads on the insights revision, so its percent of goal, steps left and goal ring stayed on the old goal until the next derive. updateProfile now bumps the revision when the step goal changes. (cherry picked from commit 3fce9adab31ff32ea496e64c9af20d726f433413) --- lib/state/app_state.dart | 8 ++++++++ test/app_state_regressions_test.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 93beac7a3..494f96ee5 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1171,10 +1171,18 @@ class AppState extends ChangeNotifier { Future> updateProfile( Map fields, ) async { + final goalBefore = user?['step_goal']; user = {...?user, ...fields}; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kProfile, jsonEncode(user)); notifyListeners(); + // Home bakes the step goal into its loaded data and re-reads only on the + // insights revision; `notifyListeners` alone left its "% of goal", "steps + // left" and goal ring on the old goal until the next derive, while the + // steps detail screen one tap away already showed the new one. + if (fields.containsKey('step_goal') && fields['step_goal'] != goalBefore) { + bumpInsights(); + } return user!; } diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index be1f0bada..71386febc 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -600,6 +600,33 @@ void main() { expect(app.alarmConfirmed, isTrue); }); + // ── a changed step goal must reach Home, which re-reads on the revision ──── + group('updateProfile step goal', () { + test('a new step goal bumps the insights revision', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 10000}); + final before = app.insightsRevision.value; + + await app.updateProfile({'step_goal': 8000}); + + expect(app.insightsRevision.value, greaterThan(before)); + expect(app.user!['step_goal'], 8000); + }); + + test('an unchanged goal or another field does not', () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 8000}); + final before = app.insightsRevision.value; + + await app.updateProfile({'step_goal': 8000}); + await app.updateProfile({'weight_kg': 70.0}); + + expect(app.insightsRevision.value, before); + }); + }); + // ── 10. dispose must release EVERYTHING AppState owns ────────────────────── group('dispose', () { testWidgets('cancels every owned timer', (t) async { From c7a5283495f779d1dc7eafdff5d60555dea8876a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:12:24 +0530 Subject: [PATCH 25/72] home said "no target yet" on past days for users with a sleep need stepping home back a day built the rings without the learned sleep need, so the sleep ring went open and claimed there was no target. the past-day load now reads the need_min the day's sleep read already carries. (cherry picked from commit b4e7394ade87f39ef8bb5e75d59ae9b0ba851803) --- lib/ui2/screens/home_screen.dart | 4 ++++ test/ui2_home_day_switcher_test.dart | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index ad863230f..516043369 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1416,6 +1416,10 @@ class HomeData { calories: metricOf(strain['calories']), caloriesTotal: metricOf(strain['calories_total']), sleepMin: metricOf(sleep['duration_min']), + // The learned need `getDaySleepV2` already carries (absent until one is + // learned). Left unset, the ring told a user who has a need that there + // was "No target yet" on every day but today. + sleepNeedMin: metricOf(sleep['need_min']), stepGoal: (profile['step_goal'] as num?)?.toInt() ?? kDefaultStepGoal, ); } diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index acb16a9f8..9083ebbc1 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -216,6 +216,18 @@ void main() { expect(t.widget(find.byType(SleepDetail)).day, isNull); }); + test('a past day carries the learned sleep need, so its ring has a target', + () async { + final d = await HomeData.loadForDay(_NeedRepo(), _yesterday); + expect(d.sleepMin.value, 250); + expect(d.sleepNeedMin.value, 465); + }); + + test('a past day with no learned need stays without a target', () async { + final d = await HomeData.loadForDay(_Repo(), _yesterday); + expect(d.sleepNeedMin.value, isNull); + }); + testWidgets('a past day with nothing recorded says so, not "sync the band"', (t) async { final app = AppState.forTesting(); @@ -359,3 +371,11 @@ class _PartialPastRepo extends _EmptyPastRepo { Future> getDayStrain(String date) async => {'strain': 9.9}; } + +/// `getDaySleepV2` as the live repo writes it once a need has been learned: +/// `need_min` is a plain minute count beside the night's duration. +class _NeedRepo extends _Repo { + @override + Future> getDaySleepV2(String date) async => + {'has_sleep': true, 'duration_min': 250, 'need_min': 465}; +} From be68db85ca7ce4abce857609ee6f30bc07b675ae Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:12:31 +0530 Subject: [PATCH 26/72] sleep screen dated a lone night with its raw yyyy-MM-dd key with only one night on disk there is no day stepper, so the nav subtitle is the only thing dating the screen, and it printed the raw day key in every locale. it now uses the same label the stepper and day steps use: 'today' or the localized day name. (cherry picked from commit 0a82c1e3ffb632c1ada6ecc0d57dc07120beebb5) --- lib/ui2/screens/sleep_detail.dart | 5 +++-- test/ui2_sleep_detail_test.dart | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index b8b6a36c6..39249dbea 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -685,9 +685,10 @@ class _SleepDetailState extends State with RevisionReload { // The stepper names the night, so the nav bar does not say it twice. With // one night on disk there is no stepper, and then the subtitle is the only - // thing that dates the screen. + // thing that dates the screen, so it names the day the way the stepper + // would rather than printing the raw yyyy-MM-dd key. return _frame(c, title, - sub: d.days.length < 2 ? (d.day ?? '').toUpperCase() : '', [ + sub: d.days.length < 2 ? dayNavLabel(d.day, l).toUpperCase() : '', [ ...dayNavRow(_day ?? d.day, d.days, _goDay), // ── 1 · THE ANSWER ── diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index af999eadc..593e887a1 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -417,6 +417,15 @@ void main() { const Metric(value: 1360, confidence: .7, tier: MetricTier.estimate), ); + // With one night on disk there is no stepper, so the subtitle dates the + // screen. It printed the raw day key ('2026-05-20') in every locale. + testWidgets('a lone night is dated by name, never by its raw key', + (t) async { + await _pump(t, SleepData(day: '2026-05-20', night: _night())); + expect(find.text('2026-05-20'), findsNothing); + expect(find.text(prettyDay('2026-05-20').toUpperCase()), findsOneWidget); + }); + testWidgets('tonight is one takeaway, not six numbers', (t) async { await _pump(t, planned(todayLabel())); expect(find.text('lights out'), findsOneWidget); From 6a74ab008dbf20bedae1e8022643000c1493972f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:15:04 +0530 Subject: [PATCH 27/72] profile setup said any of four numbers could be left blank the intro counted sex as one of four optional numbers, but sex is a choice and continue stays disabled until one is picked. the copy now says sex is the one required choice and that age, height and weight can be left blank. (cherry picked from commit 765ca08e58dc6fe01bd158e3162c978d2829be34) --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/onboarding/profile_setup.dart | 6 +++-- test/profile_setup_copy_test.dart | 34 +++++++++++++++++++++++++++ 9 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 test/profile_setup_copy_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 862e9de12..81cfc6ce2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Band gefunden, aber Verbindung nicht abgeschlossen", "pairingFailedAdviceBody": "Meist klappt es, wenn du aus etwa einem Meter Entfernung erneut suchst.", "profileSetupTitle": "Über dich", - "profileSetupBody": "Vier Angaben beeinflussen, wie deine Daten ausgewertet werden. Lässt du eine leer, bleiben nur die Werte aus, die sie benötigen.", + "profileSetupBody": "Das Geschlecht ist die einzige Angabe, die zum Fortfahren nötig ist. Alter, Größe und Gewicht beeinflussen, wie deine Daten ausgewertet werden; lässt du eine davon leer, bleiben nur die Werte aus, die sie benötigen.", "profileSetupSexHeader": "GESCHLECHT", "profileSetupSexMale": "Männlich", "profileSetupSexFemale": "Weiblich", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 900098abf..af268dcc4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -176,7 +176,7 @@ "@profileSetupTitle": { "description": "Profile setup screen header." }, - "profileSetupBody": "Four numbers change how your data is scored. Leave any of them blank and only the metrics that need it stay unavailable.", + "profileSetupBody": "Sex is the one choice you need to make to continue. Age, height and weight change how your data is scored; leave any of them blank and only the metrics that need it stay unavailable.", "@profileSetupBody": { "description": "Profile setup intro body." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 60dec392b..728fe7db5 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Se encontró la banda pero la sesión no terminó", "pairingFailedAdviceBody": "Volver a buscar desde un metro de distancia suele funcionar.", "profileSetupTitle": "Sobre ti", - "profileSetupBody": "Cuatro datos cambian cómo se puntúan tus datos. Deja alguno en blanco y solo las métricas que lo necesiten quedarán sin disponibilidad.", + "profileSetupBody": "El sexo es la única opción obligatoria para continuar. La edad, la altura y el peso cambian cómo se puntúan tus datos; deja alguno en blanco y solo las métricas que lo necesiten quedarán sin disponibilidad.", "profileSetupSexHeader": "SEXO", "profileSetupSexMale": "Hombre", "profileSetupSexFemale": "Mujer", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0f86cd090..6e810dfe5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "Le bracelet a été trouvé mais la session ne s’est pas terminée", "pairingFailedAdviceBody": "Relancer la recherche à un mètre de distance fonctionne généralement.", "profileSetupTitle": "À propos de vous", - "profileSetupBody": "Quatre chiffres modifient la façon dont vos données sont notées. Laissez-en un vide et seules les métriques qui en ont besoin resteront indisponibles.", + "profileSetupBody": "Le sexe est le seul choix obligatoire pour continuer. L'âge, la taille et le poids modifient la façon dont vos données sont notées ; laissez-en un vide et seules les métriques qui en ont besoin resteront indisponibles.", "profileSetupSexHeader": "SEXE", "profileSetupSexMale": "Homme", "profileSetupSexFemale": "Femme", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ac367773d..2e34239ac 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "बैंड मिल गया लेकिन सत्र पूरा नहीं हुआ", "pairingFailedAdviceBody": "एक मीटर की दूरी से दोबारा स्कैन करना आमतौर पर काम करता है।", "profileSetupTitle": "आपके बारे में", - "profileSetupBody": "चार संख्याएं आपके डेटा के स्कोर होने के तरीके को बदलती हैं। किसी को भी खाली छोड़ें, केवल उसकी आवश्यकता वाले मेट्रिक्स ही अनुपलब्ध रहेंगे।", + "profileSetupBody": "आगे बढ़ने के लिए केवल लिंग चुनना ज़रूरी है। उम्र, लंबाई और वज़न आपके डेटा के स्कोर होने के तरीके को बदलते हैं; इनमें से किसी को भी खाली छोड़ें, केवल उसकी आवश्यकता वाले मेट्रिक्स ही अनुपलब्ध रहेंगे।", "profileSetupSexHeader": "लिंग", "profileSetupSexMale": "पुरुष", "profileSetupSexFemale": "महिला", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 232b8cf31..fec6b16e8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -43,7 +43,7 @@ "pairingFailedAdviceTitle": "Браслет найден, но подключение не завершилось", "pairingFailedAdviceBody": "Обычно помогает повторный поиск с расстояния около метра.", "profileSetupTitle": "О вас", - "profileSetupBody": "Четыре параметра влияют на расчёт ваших показателей. Если оставить какой-либо из них пустым, недоступными останутся только зависящие от него показатели.", + "profileSetupBody": "Чтобы продолжить, нужно указать только пол. Возраст, рост и вес влияют на расчёт ваших показателей; если оставить какой-либо из них пустым, недоступными останутся только зависящие от него показатели.", "profileSetupSexHeader": "ПОЛ", "profileSetupSexMale": "Мужской", "profileSetupSexFemale": "Женский", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0028a08ea..5ad942830 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -44,7 +44,7 @@ "pairingFailedAdviceTitle": "找到了腕带,但会话未完成", "pairingFailedAdviceBody": "从一米距离再次扫描通常有效。", "profileSetupTitle": "关于你", - "profileSetupBody": "四个数字会影响你的数据评分方式。留空其中任何一个,只有需要它的指标会不可用。", + "profileSetupBody": "继续前只需选择性别。年龄、身高和体重会影响你的数据评分方式;留空其中任何一个,只有需要它的指标会不可用。", "profileSetupSexHeader": "性别", "profileSetupSexMale": "男性", "profileSetupSexFemale": "女性", diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 2b5b473bd..8edc7de61 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -178,8 +178,10 @@ class _ProfileSetupViewState extends State { const SizedBox(height: S.x3), Text( l?.profileSetupBody ?? - 'Four numbers change how your data is scored. Leave any of them ' - 'blank and only the metrics that need it stay unavailable.', + 'Sex is the one choice you need to make to continue. Age, ' + 'height and weight change how your data is scored; leave ' + 'any of them blank and only the metrics that need it stay ' + 'unavailable.', style: F.body.copyWith(color: p.ink2), ), const SizedBox(height: S.x6), diff --git a/test/profile_setup_copy_test.dart b/test/profile_setup_copy_test.dart new file mode 100644 index 000000000..aef0ca591 --- /dev/null +++ b/test/profile_setup_copy_test.dart @@ -0,0 +1,34 @@ +// The profile setup intro used to say "four numbers" could each be left +// blank, while Continue stays disabled until a sex is picked. Sex is not a +// number and is not optional, so the copy has to say it is required. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('profile setup intro names sex as required, not as a blankable number', + () { + final arb = jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync()) + as Map; + final body = arb['profileSetupBody'] as String; + expect(body.toLowerCase(), isNot(contains('four numbers'))); + expect(body, contains('Sex')); + expect(body.toLowerCase(), contains('continue')); + + final src = + File('lib/ui2/onboarding/profile_setup.dart').readAsStringSync(); + expect(src, isNot(contains('Four numbers'))); + }); + + test('every locale carries the profile setup intro', () { + for (final loc in const ['de', 'en', 'es', 'fr', 'hi', 'ru', 'zh']) { + final arb = jsonDecode(File('lib/l10n/app_$loc.arb').readAsStringSync()) + as Map; + expect(arb['profileSetupBody'], isA(), reason: loc); + expect((arb['profileSetupBody'] as String).isNotEmpty, isTrue, + reason: loc); + } + }); +} From 30d70d20c4562b614ae0cde48377fa24ff5e51ed Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:13:15 +0530 Subject: [PATCH 28/72] home kept the old day's rings under a new day's stepper stepping to another day left the previous day's data on screen while the new one loaded, with no spinner, and kept it there for good if the read threw: the stepper named the new day while the rings, glance tiles and greeting date were still the old day's, and the failure card never showed because it only renders when there is no day loaded. the switch now clears the day on screen, so the load shows the spinner and a failed read shows the failure card, titled with the day that failed and with a way back to today. (cherry picked from commit 9bcc6c6ab9b0845718446a0a9dfa09e6d213ab90) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 10 +++++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/home_screen.dart | 21 ++++++++++++++- test/ui2_home_day_switcher_test.dart | 40 ++++++++++++++++++++++++++++ 9 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 81cfc6ce2..5ad20e3ec 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1478,6 +1478,7 @@ "homeOfSpan": "von {duration}", "homeLoadFailedTitle": "Der heutige Tag konnte nicht geladen werden", "homeLoadFailedBody": "Der gespeicherte Tag konnte nicht geladen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", + "homeDayLoadFailedTitle": "{day} konnte nicht geladen werden", "wellnessLoadFailedTitle": "Konnte nicht gelesen werden", "wellnessLoadFailedBody": "Die gespeicherten Daten konnten nicht gelesen werden. Es wurde nichts gelöscht – das ist ein Ladefehler, es fehlen keine Daten.", "homeTryAgain": "Erneut versuchen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index af268dcc4..bad0b2989 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7329,6 +7329,16 @@ "@homeLoadFailedBody": { "description": "Body of the card shown when today's data failed to load from the database." }, + "homeDayLoadFailedTitle": "{day} could not be read", + "@homeDayLoadFailedTitle": { + "description": "Title of the card shown on Home when a past day picked with the day switcher failed to load from the database.", + "placeholders": { + "day": { + "type": "String", + "example": "Wednesday, 15 October" + } + } + }, "wellnessLoadFailedTitle": "This could not be read", "@wellnessLoadFailedTitle": { "description": "Title of the card shown when the Wellness screen or the sleep plan failed to load from the database." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 728fe7db5..d93850b00 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1443,6 +1443,7 @@ "homeOfSpan": "de {duration}", "homeLoadFailedTitle": "No se pudo leer el día de hoy", "homeLoadFailedBody": "No se pudo cargar el día guardado. No se eliminó nada; fue un error de lectura, no datos perdidos.", + "homeDayLoadFailedTitle": "No se pudo leer {day}", "wellnessLoadFailedTitle": "No se pudo leer", "wellnessLoadFailedBody": "No se pudieron leer los datos guardados. No se eliminó nada; fue un error de lectura, no datos perdidos.", "homeTryAgain": "Intentar de nuevo", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6e810dfe5..d01085608 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1443,6 +1443,7 @@ "homeOfSpan": "sur {duration}", "homeLoadFailedTitle": "Impossible de lire les données d'aujourd'hui", "homeLoadFailedBody": "La journée enregistrée n'a pas pu être chargée. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", + "homeDayLoadFailedTitle": "Impossible de lire {day}", "wellnessLoadFailedTitle": "Lecture impossible", "wellnessLoadFailedBody": "Les données enregistrées n'ont pas pu être lues. Rien n'a été supprimé : c'est une erreur de lecture, pas une perte de données.", "homeTryAgain": "Réessayer", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 2e34239ac..988f76f41 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1443,6 +1443,7 @@ "homeOfSpan": "{duration} में से", "homeLoadFailedTitle": "आज का डेटा पढ़ा नहीं जा सका", "homeLoadFailedBody": "सहेजा गया दिन लोड नहीं हो सका। कुछ भी हटाया नहीं गया — यह एक रीड एरर है, डेटा खोना नहीं।", + "homeDayLoadFailedTitle": "{day} का डेटा पढ़ा नहीं जा सका", "wellnessLoadFailedTitle": "इसे पढ़ा नहीं जा सका", "wellnessLoadFailedBody": "आपका सहेजा गया डेटा पढ़ने में विफलता हुई। कुछ भी हटाया नहीं गया — यह पढ़ने में हुई गड़बड़ी है, डेटा गायब नहीं है।", "homeTryAgain": "फिर से कोशिश करें", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fec6b16e8..40e3ac212 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3358,6 +3358,7 @@ }, "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", + "homeDayLoadFailedTitle": "Не удалось прочитать {day}", "wellnessLoadFailedTitle": "Не удалось прочитать", "wellnessLoadFailedBody": "Не удалось прочитать сохранённые данные. Ничего не удалено — это сбой чтения, а не отсутствие данных.", "homeTryAgain": "Повторить", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5ad942830..0cfcab012 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1443,6 +1443,7 @@ "homeOfSpan": "共 {duration}", "homeLoadFailedTitle": "今日数据读取失败", "homeLoadFailedBody": "已保存的当日数据加载失败。没有任何内容被删除——这是读取出错,不是数据丢失。", + "homeDayLoadFailedTitle": "{day}的数据读取失败", "wellnessLoadFailedTitle": "无法读取", "wellnessLoadFailedBody": "读取已存储的数据失败。没有删除任何内容——这是一次读取出错,而不是数据缺失。", "homeTryAgain": "重试", diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 516043369..6ba31c2bb 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1631,7 +1631,13 @@ class _HomeScreenState extends State with RevisionReload { void _goDay(String day) { setState(() { _day = homeSwitcherDay(day); + // The day on screen goes with the switch. Kept, it stayed under the new + // day's stepper for the whole load with no spinner, and for good if the + // load threw: the rings, tiles and greeting date were still the old + // day's, and the failure card only renders when there is no day. + _d = null; _loading = true; + _failed = false; }); _load(); } @@ -1773,7 +1779,10 @@ class _HomeScreenState extends State with RevisionReload { const Center(child: CircularProgressIndicator()) else if (_failed) StatusCard( - l?.homeLoadFailedTitle ?? 'Today could not be read', + _day == null + ? (l?.homeLoadFailedTitle ?? 'Today could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(_day, l)) ?? + '${prettyDay(_day, l)} could not be read'), l?.homeLoadFailedBody ?? 'The stored day failed to load. Nothing was deleted — this is a ' 'read that went wrong, not missing data.', @@ -1797,6 +1806,16 @@ class _HomeScreenState extends State with RevisionReload { onFix: sync == null ? null : () => _tapSync(sync), ); }), + // A failed read of a day stepped back onto has no stepper on this + // path, so this is the way home. + if (_day != null && _day != todayLabel()) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => _goDay(todayLabel()), + child: Text(l?.homeBackToToday ?? 'Back to today'), + ), + ), if (!_loading && !_failed && widget.data == null) ...[ const SizedBox(height: S.x3), firstWeekCard(c, days: _days.length, strainScored: false), diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 9083ebbc1..57604d8c6 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -318,6 +318,39 @@ void main() { expect(find.text('Set an alarm'), findsOneWidget); }); + // A day switch kept the old day on screen while it loaded, and for good if + // the read threw: the stepper named yesterday while the rings, tiles and + // greeting date were still today's, and nothing said the read had failed. + testWidgets('a past day that fails to load does not keep today on screen', + (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _PastDayThrowsRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + expect(find.text('70'), findsOneWidget); + + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + + expect(find.text('70'), findsNothing); + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('Today could not be read'), findsNothing); + + await t.ensureVisible(find.text('Back to today')); + await t.tap(find.text('Back to today')); + await _settle(t); + expect(find.text('70'), findsOneWidget); + expect(find.textContaining('could not be read'), findsNothing); + }); + // Home is never disposed (the shell's IndexedStack), so "Back to today" // storing today's literal date left the tab pinned to that date once the // clock crossed midnight. @@ -379,3 +412,10 @@ class _NeedRepo extends _Repo { Future> getDaySleepV2(String date) async => {'has_sleep': true, 'duration_min': 250, 'need_min': 465}; } + +/// Today reads fine; any past day throws on its first per-day read. +class _PastDayThrowsRepo extends _Repo { + @override + Future> getDayOverview(String date) async => + throw StateError('read failed'); +} From 09c1b23d9e22d79f3cfd0026406c2c9ab647f642 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:14:29 +0530 Subject: [PATCH 29/72] typed numbers rejected every decimal comma the iOS decimal pad on a german, french, spanish or russian region phone has a comma key and no dot, so no fractional lab value, weight, portion or height could ever be saved. a comma is now read as a decimal when it can't be a thousands separator ("4,5", "0,125", "1234,5"); "1,200" still counts as unreadable and the form asks. the profile weight/height parser uses the same rule. (cherry picked from commit 09d2d7bd1d744c5d00aa93c355ecf9e95f59ecb5) --- lib/l10n/decimal_text.dart | 27 +++++++++++++++++++++++++++ lib/state/units_controller.dart | 6 ++++-- lib/ui2/grammar.dart | 16 +++++++--------- test/ui2/grammar_test.dart | 25 +++++++++++++++++++++++++ test/ui2_router_test.dart | 6 +++--- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/lib/l10n/decimal_text.dart b/lib/l10n/decimal_text.dart index 584960d54..7eea236e7 100644 --- a/lib/l10n/decimal_text.dart +++ b/lib/l10n/decimal_text.dart @@ -7,3 +7,30 @@ String displayFixed(num value, int places) => NumberFormat( places == 0 ? '0' : '0.${'0' * places}', LocaleController.displayLanguageCode, ).format(value); + +final RegExp _commaDecimal = RegExp(r'^([+-]?)(\d+),(\d+)$'); + +/// A number a person typed, or null when it cannot be read as one. +/// +/// A decimal comma is accepted when it can only be a decimal: the iOS decimal +/// pad on a German, French, Spanish or Russian region phone has a ',' key and +/// no '.', so rejecting every comma left those users unable to type any +/// fraction at all. "4,5" is never a thousands grouping (a group is always +/// three digits), so it is 4.5. "1,200" is a grouping to half the world and a +/// decimal to the other half, so it stays unreadable and the user is asked, +/// never guessed for. Anything else falls through to [double.tryParse], minus +/// the non-finite literals it accepts ("Infinity", "NaN", "1e400"). +double? parseTypedNumber(String text) { + final s = text.trim(); + final direct = double.tryParse(s); + if (direct != null) return direct.isFinite ? direct : null; + final m = _commaDecimal.firstMatch(s); + if (m == null) return null; + final whole = m.group(2)!; + final frac = m.group(3)!; + final couldBeGrouping = + frac.length == 3 && whole.length <= 3 && !whole.startsWith('0'); + if (couldBeGrouping) return null; + final v = double.tryParse('${m.group(1)}$whole.$frac'); + return v != null && v.isFinite ? v : null; +} diff --git a/lib/state/units_controller.dart b/lib/state/units_controller.dart index 09dd8adb7..413d0b7f0 100644 --- a/lib/state/units_controller.dart +++ b/lib/state/units_controller.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../l10n/decimal_text.dart' show parseTypedNumber; + enum UnitSystem { metric, imperial } extension UnitSystemLabel on UnitSystem { @@ -175,14 +177,14 @@ class UnitsController extends ChangeNotifier { /// Parse a weight field (display units) → kg for storage. double? weightToKg(String text) { - final v = double.tryParse(text.trim()); + final v = parseTypedNumber(text); if (v == null) return null; return isImperial ? v * _kgPerLb : v; } /// Parse a height field (display units = total inches when imperial) → cm. double? heightToCm(String text) { - final v = double.tryParse(text.trim()); + final v = parseTypedNumber(text); if (v == null) return null; return isImperial ? v * _cmPerIn : v; } diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 0a23e31b6..949d56d2c 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -31,6 +31,7 @@ import '../l10n/display_text.dart'; import '../l10n/app_localizations.dart'; +import '../l10n/decimal_text.dart' show parseTypedNumber; import '../l10n/presentation_text.dart'; import 'dart:math' as math; @@ -1789,9 +1790,10 @@ class Consistency extends StatelessWidget { /// occasion, and a lab value with its unit on it dropped the whole result — /// each of them under a screen that closed as if it had saved. /// -/// Nothing here guesses. "1,5" is 1.5 to half of Europe and 15 to the other -/// half, so it is [bad] and the user is asked, never averaged into a number -/// they did not type. +/// Nothing here guesses. A decimal comma is read only where it cannot be a +/// thousands separator ("4,5"); "1,200" is 1.2 to half of Europe and 1200 to +/// the other half, so it is [bad] and the user is asked, never averaged into a +/// number they did not type. See [parseTypedNumber]. class Typed { const Typed._(this.value, this.bad); @@ -1806,12 +1808,8 @@ class Typed { static Typed of(String text) { final s = text.trim(); if (s.isEmpty) return const Typed._(null, false); - final v = double.tryParse(s); - // tryParse accepts "Infinity"/"-Infinity"/"NaN" and overflows huge - // literals ("1e400") to infinity — none of those are a real number. - return v == null || !v.isFinite - ? const Typed._(null, true) - : Typed._(v, false); + final v = parseTypedNumber(s); + return v == null ? const Typed._(null, true) : Typed._(v, false); } } diff --git a/test/ui2/grammar_test.dart b/test/ui2/grammar_test.dart index c9dde9ca6..fc13470ae 100644 --- a/test/ui2/grammar_test.dart +++ b/test/ui2/grammar_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; import 'package:openstrap_edge/ui2/grammar.dart'; void main() { @@ -17,6 +18,30 @@ void main() { expect(t.value, 180.0); }); + test('reads a decimal comma that cannot be a thousands separator', () { + // The iOS decimal pad on a comma-locale phone has no '.' key. + expect(Typed.of('4,5').value, 4.5); + expect(Typed.of('72,25').value, 72.25); + expect(Typed.of('0,125').value, 0.125); + expect(Typed.of('1234,567').value, 1234.567); + expect(Typed.of('-0,5').value, -0.5); + expect(Typed.of(' 4,5 ').bad, isFalse); + }); + + test('a comma that could be grouping is still unreadable', () { + for (final s in ['1,200', '72,500', '1,2,3', '1,5.0', ',5', '5,']) { + expect(Typed.of(s).bad, isTrue, reason: s); + expect(Typed.of(s).value, isNull, reason: s); + } + }); + + test('profile weight and height take a decimal comma too', () { + final u = UnitsController.seed(UnitSystem.metric); + expect(u.weightToKg('72,5'), 72.5); + expect(u.heightToCm('180,5'), 180.5); + expect(u.weightToKg('1,200'), isNull); + }); + test('blank text is blank, not bad', () { final t = Typed.of(' '); expect(t.blank, isTrue); diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 16b0f4c4a..45b15f9e8 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -374,9 +374,9 @@ void main() { expect(Typed.of(' ').blank, isTrue); expect(Typed.of(' ').bad, isFalse); expect(Typed.of('78.4').value, 78.4); - // A comma decimal is 1.5 to half of Europe and 15 to the other half, and - // a unit suffix is not a number at all. Neither is guessed at. - for (final s in ['78 kg', '1,5', '1,200', 'twelve']) { + // "1,200" is 1.2 to half of Europe and 1200 to the other half, and a + // unit suffix is not a number at all. Neither is guessed at. + for (final s in ['78 kg', '1,200', 'twelve']) { expect(Typed.of(s).bad, isTrue, reason: s); expect(Typed.of(s).value, isNull, reason: s); } From 42bd7ebd73241897b166904ab7acf660bc01fb25 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 22 Oct 2026 16:15:34 +0530 Subject: [PATCH 30/72] home told an unpaired user to sync a band they don't have skipping pairing at onboarding left home on "no nights scored yet" with a "sync the band" button. the sync returned straight away with nothing paired, the tap grace showed "connecting to your band" for 20 s anyway, and nothing on home led to pairing. with no band paired the empty-day card now says so and offers "pair a band", which opens the same pairing flow devices uses. (cherry picked from commit 172f5d86f10618b876f92631b644a5732ea7b835) --- lib/ui2/screens/home_screen.dart | 25 +++++++++++++- test/ui2_home_no_band_test.dart | 59 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 test/ui2_home_no_band_test.dart diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 6ba31c2bb..47214c546 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -65,7 +65,7 @@ import '../../theme/theme_switcher.dart' show themedRoute; import '../activity/day_strain.dart' show DayStrainDetail; import '../profile/alarm.dart' show AlarmArmState, alarmArmOf, alarmDoor, alarmGlanceCard; -import '../profile/devices.dart' show formatDayTime; +import '../profile/devices.dart' show RePair, formatDayTime; import '../profile/profile.dart'; import '../profile/wearable_numbers.dart' show WearableCells, kHomeWearableRows; @@ -136,6 +136,27 @@ VoidCallback? syncOf(BuildContext c) { } } +/// The card Home shows instead of "Sync the band" when no band is paired, or +/// null when one is (or there is no AppState above us). Onboarding's "Skip for +/// now" opens the app with nothing paired; a sync then returns at once, the +/// tap grace held "Connecting to your band" up for 20 s over nothing, and +/// nothing on Home pointed at pairing. +Widget? noBandCard(BuildContext c, AppLocalizations? l) { + try { + if (c.read().isPaired) return null; + } catch (_) { + return null; + } + return StatusCard( + l?.devicesNoBandPaired ?? 'No band is paired', + l?.pairingSkipNote ?? + 'The app opens without a band. Nothing is measured until one is paired.', + fix: l?.devicesPairABand ?? 'Pair a band', + icon: LucideIcons.watch, + onFix: () => goto(c, const RePair()), + ); +} + /// Whether the database had to be rebuilt to start this launch, or null in a /// golden. Same shape as [repoOf] and [syncOf]. DbRebuild? dbRebuildOf(BuildContext c) { @@ -1720,6 +1741,7 @@ class _HomeScreenState extends State with RevisionReload { } final phase = _phaseStatusCard(c, l); if (phase != null) return phase; + if (noBandCard(c, l) case final noBand?) return noBand; final sync = syncOf(c); return StatusCard( @@ -1797,6 +1819,7 @@ class _HomeScreenState extends State with RevisionReload { Builder(builder: (c) { final phase = _phaseStatusCard(c, l); if (phase != null) return phase; + if (noBandCard(c, l) case final noBand?) return noBand; final sync = syncOf(c); return StatusCard( l?.homeNothingDerivedTitle ?? 'No nights scored yet', diff --git a/test/ui2_home_no_band_test.dart b/test/ui2_home_no_band_test.dart new file mode 100644 index 000000000..a5b8a2623 --- /dev/null +++ b/test/ui2_home_no_band_test.dart @@ -0,0 +1,59 @@ +// "Skip for now" at onboarding opens the app with no band paired. Home's +// first-run card still offered "Sync the band": the sync returned at once, +// the tap grace held "Connecting to your band" up for 20 s over nothing, and +// nothing on Home pointed at pairing. With no band, the card offers pairing. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/paired_device.dart' show PairedDevice; +import 'package:openstrap_edge/ui2/profile/devices.dart' show RePair; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _host(AppState? app) { + final probe = Builder( + builder: (c) => + Scaffold(body: noBandCard(c, null) ?? const Text('no card')), + ); + final home = MaterialApp(theme: buildTheme(Brightness.light), home: probe); + return app == null + ? home + : ChangeNotifierProvider.value(value: app, child: home); +} + +void main() { + testWidgets('no band paired: Home offers pairing, not a sync', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + + await t.pumpWidget(_host(app)); + + expect(find.text('No band is paired'), findsOneWidget); + expect(find.text('Pair a band'), findsOneWidget); + expect(find.text('Sync the band'), findsNothing); + + await t.tap(find.text('Pair a band')); + await t.pump(); + await t.pump(const Duration(milliseconds: 400)); + expect(find.byType(RePair), findsOneWidget); + }); + + testWidgets('a paired band gets no pairing card', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.paired = PairedDevice('AA:BB:CC:DD:EE:FF', 'SER1'); + + await t.pumpWidget(_host(app)); + + expect(find.text('No band is paired'), findsNothing); + expect(find.text('no card'), findsOneWidget); + }); + + testWidgets('no AppState above (goldens) gets no pairing card', (t) async { + await t.pumpWidget(_host(null)); + expect(find.text('no card'), findsOneWidget); + }); +} From 0840f109dbea6000ff0df90330ac26c087be4159 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:52:27 +0530 Subject: [PATCH 31/72] rough-night causes were logged on the morning, so they explained the next night the rough-night card wrote every picked tag on the wake day. the journal correlations read a behaviour tag (alcohol, late meal, caffeine...) against the night that follows its date, so a drink logged as the cause of last night was credited to tonight, and last night never got it. each tag now goes on the day journalTagDayForNight picks for it: behaviour tags on the evening before, sick/stress on the night itself. 'add how much' opens the editor on the evening before for the same reason. (cherry picked from commit 72f0b4e4518bfc537794575373b2d07001d7783b) --- lib/ui2/screens/rough_night.dart | 60 ++++++++++++++++++++++++-------- test/rough_night_test.dart | 23 ++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/lib/ui2/screens/rough_night.dart b/lib/ui2/screens/rough_night.dart index 53e349b74..ea93b9605 100644 --- a/lib/ui2/screens/rough_night.dart +++ b/lib/ui2/screens/rough_night.dart @@ -203,6 +203,25 @@ class RoughNight { return (signs: signs, moved: moved, tempMoved: tempMoved); } +/// The journal day each [picked] tag is stored on when given as the cause of +/// the night ending the morning of [day], grouped by that day. +/// +/// The correlation engine reads a tag dated D against the night that follows +/// it for behaviour tags (alcohol, a late meal, caffeine...) and against the +/// night already over for retrospective ones (sick, stress). Writing every +/// pick on [day] would credit the behaviour to the next night instead of this +/// one, so each tag goes where the engine will look for it. +Map> roughNightTagDays( + String day, + Iterable picked, +) { + final out = >{}; + for (final t in picked) { + (out[ana.journalTagDayForNight(day, t) ?? day] ??= []).add(t); + } + return out; +} + /// When the last session that ran late into the evening BEFORE [day]'s night /// ended, or null. [day]'s night is the one that ended that morning, so it /// started the previous evening: only a session that began that previous @@ -400,7 +419,9 @@ class _RoughNightCardState extends State { /// Agreeing writes the tags the user picked onto that night, MERGED into /// whatever was already logged. /// - /// `postJournal` replaces the row's whole tag set, so the stored day is read + /// Each tag lands on the day [roughNightTagDays] picks for it — behaviour + /// tags on the evening before, so they pair with this night and not the next. + /// `postJournal` replaces the row's whole tag set, so each stored day is read /// first — the same read-merge the compose screen does. Nothing numeric is /// written: the card asked whether, not how much, and a quantity nobody gave /// is a fabricated one. "Add how much" leads to the editor that does ask. @@ -410,19 +431,24 @@ class _RoughNightCardState extends State { try { final repo = context.read().repo; if (repo == null) return; - Map? row; + final byDay = roughNightTagDays(widget.night.day, _picked); + final rows = >{}; for (final e in await repo.getJournal(range: '30d')) { - if (e['date'] == widget.night.day) row = e; + final d = e['date']; + if (d is String && byDay.containsKey(d)) rows[d] = e; + } + for (final MapEntry(key: day, value: picked) in byDay.entries) { + final row = rows[day]; + final tags = { + ...?(row?['tags'] as List?)?.map((t) => t.toString()), + ...picked, + }; + await repo.postJournal( + day, + tags.toList(), + (row?['note'] as String?) ?? '', + ); } - final tags = { - ...?(row?['tags'] as List?)?.map((t) => t.toString()), - ..._picked, - }; - await repo.postJournal( - widget.night.day, - tags.toList(), - (row?['note'] as String?) ?? '', - ); _dismiss(); } finally { if (mounted) setState(() => _saving = false); @@ -565,10 +591,16 @@ class _RoughNightCardState extends State { ), const SizedBox(height: S.x3), // The amounts live in the editor that already asks for them, and the - // amount is what the correlation engine actually reads. + // amount is what the correlation engine actually reads. Every dose the + // card's tags stand for (alcohol units, caffeine, screens) is read against + // the night after the day it is logged on, so the editor opens on the + // evening before this night. Pressable( onTap: () => Navigator.of(c).push( - MaterialPageRoute(builder: (_) => JournalCompose(date: n.day)), + MaterialPageRoute( + builder: (_) => + JournalCompose(date: ana.shiftDayLabel(n.day, -1) ?? n.day), + ), ), child: Text( l?.roughNightAddHowMuch ?? 'Add how much', diff --git a/test/rough_night_test.dart b/test/rough_night_test.dart index f94ec0781..d4a819bc2 100644 --- a/test/rough_night_test.dart +++ b/test/rough_night_test.dart @@ -172,6 +172,29 @@ void main() { }); }); + group('where the attribution is written', () { + // The correlation engine pairs a behaviour tag dated D with the night + // ending D+1. A cause picked on the wake day for the night it ended must + // go on the evening before, or it explains the next night instead. + test('behaviour tags go on the evening before the night', () { + final days = roughNightTagDays('2026-10-10', ['alcohol', 'late meal']); + expect(days, { + '2026-10-09': ['alcohol', 'late meal'], + }); + }); + + test('retrospective tags stay on the night itself', () { + final days = roughNightTagDays('2026-10-10', ['alcohol', 'sick']); + expect(days['2026-10-09'], ['alcohol']); + expect(days['2026-10-10'], ['sick']); + }); + + test('crosses a month boundary', () { + expect(roughNightTagDays('2026-11-01', ['caffeine']).keys, + ['2026-10-31']); + }); + }); + group('what the card says', () { const night = RoughNight( day: '2026-08-15', From f42742fb6bfb5a1c2e2c74b04108433009ce351f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:56:04 +0530 Subject: [PATCH 32/72] correcting last night from the sleep tab pinned the tab to that night the correction pinned the edited night so the reload couldn't wander, but nothing on the tab ever released it. once the next night synced the tab kept showing the corrected one and dropped the sleep plan until the arrows were touched. the pin is now released after the re-derive when the edited night is still the newest one. (cherry picked from commit 9646686b598eb02dde479dbbde0191f61fc87fc6) --- lib/ui2/screens/sleep_detail.dart | 13 +++++++++++++ test/ui2_sleep_detail_test.dart | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 39249dbea..aa3bd60d4 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -390,6 +390,18 @@ class SleepData { String? sleepStepperDay(String day, String? latestDay) => day == latestDay ? null : day; +/// What the Sleep screen stores for its night once a correction to [edited] +/// has re-derived. The edit pins [edited] so the reload can't wander to +/// another night, but on the tab that pin is never cleared by anything else: +/// once a newer night arrived the tab stayed on the corrected one and lost +/// the sleep plan, until the arrows were touched. So the pin is released when +/// the edited night is still the newest one. A night the user stepped to +/// meanwhile ([current] != [edited]) is left alone. +@visibleForTesting +String? sleepDayAfterOverride( + String? current, String edited, String? latestDay) => + current == edited ? sleepStepperDay(edited, latestDay) : current; + class SleepDetail extends StatefulWidget { final SleepData? data; @@ -1103,6 +1115,7 @@ class _SleepDetailState extends State with RevisionReload { if (_day == null && _d?.day == day) _day = day; if (mounted) await _load(); if (!mounted) return; + _day = sleepDayAfterOverride(_day, day, _d?.latestDay); // EVERY failure below this screen is silent: the forced re-derive catches // and logs its own throw, and it returns at its first line when another // re-analysis is already running. Both leave the write in the database and diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index 593e887a1..12342834b 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -485,6 +485,24 @@ void main() { }); }); + group('sleepDayAfterOverride', () { + test('a correction to the newest night stops pinning it', () { + expect(sleepDayAfterOverride('2026-10-17', '2026-10-17', '2026-10-17'), + isNull); + }); + + test('an edited night that is no longer the newest stays pinned', () { + expect(sleepDayAfterOverride('2026-10-16', '2026-10-16', '2026-10-17'), + '2026-10-16'); + }); + + test('a night the user stepped to meanwhile is left alone', () { + expect(sleepDayAfterOverride('2026-10-15', '2026-10-17', '2026-10-17'), + '2026-10-15'); + expect(sleepDayAfterOverride(null, '2026-10-16', '2026-10-17'), isNull); + }); + }); + group('the Sleep tab', () { Future tab(WidgetTester t, String day, {String? latest}) async { t.view.physicalSize = const Size(390 * 3, 5200 * 3); From 1365cbf7c5b146bea3b950ef1275bee4f91a8ccc Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:49 +0530 Subject: [PATCH 33/72] a failed sleep read said there was no night, or kept the old one under the new date when the sleep tab's read threw on the first load it fell through to the no-night card and told the user to wear the band overnight, with no way to retry. a failed day step left the previous night on screen under the new date, and its window buttons (not sleep, set the times, back to automatic) still acted on that previous night. a failed read now shows a retryable could-not-be-read card under the stepper, the window and undo buttons wait while a step is loading like the naps button already did, and the day timeline gets the same failure card. (cherry picked from commit e3141f5a68139ca9b74b64b3520f6c132ced1323) --- lib/ui2/screens/day_timeline.dart | 30 +++- lib/ui2/screens/sleep_detail.dart | 54 ++++++- test/ui2_sleep_detail_load_failure_test.dart | 145 +++++++++++++++++++ 3 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 test/ui2_sleep_detail_load_failure_test.dart diff --git a/lib/ui2/screens/day_timeline.dart b/lib/ui2/screens/day_timeline.dart index c4b7b6a12..65e0c4b5d 100644 --- a/lib/ui2/screens/day_timeline.dart +++ b/lib/ui2/screens/day_timeline.dart @@ -49,7 +49,7 @@ import '../profile/devices.dart' show DeviceFilter, DeviceOption, signalCandidates; import '../profile/wearable_numbers.dart' show deviceScoreLabel, observationTitle; import '../ui2.dart'; -import 'home_screen.dart' show clockOfTs, repoOf; +import 'home_screen.dart' show clockOfTs, prettyDay, repoOf; import 'metric_detail.dart' show dayNavRow, detailScaffold, pickDay; /// One thing that happened, at a time it is known to have happened at. @@ -749,6 +749,11 @@ class _DayTimelineScreenState extends State { bool _loading = true; String? _day; + /// The last read threw. Without it a failed day step drew the previous + /// day's timeline under the new date, and a failed first read drew an empty + /// day. + bool _failed = false; + /// The devices that could serve `hr` on this screen — registry-only, no /// query. Empty on every single-device install, which keeps the filter row /// absent (M6 §7.3). @@ -840,10 +845,13 @@ class _DayTimelineScreenState extends State { _deviceBounded = false, _deviceOldest = null, _loading = false, + _failed = false, )); } } catch (_) { - if (mounted && token == _loadToken) setState(() => _loading = false); + if (mounted && token == _loadToken) { + setState(() => (_loading = false, _failed = true)); + } } } @@ -905,6 +913,7 @@ class _DayTimelineScreenState extends State { setState(() { _day = day; _loading = true; + _failed = false; }); _load(); } @@ -919,6 +928,23 @@ class _DayTimelineScreenState extends State { if (_loading) ...[ const SizedBox(height: S.x8), const Center(child: CircularProgressIndicator()), + ] else if (_failed) ...[ + const SizedBox(height: S.x2), + StatusCard( + (_day ?? d.day) == null + ? (l?.wellnessLoadFailedTitle ?? 'This could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(_day ?? d.day, l)) ?? + '${prettyDay(_day ?? d.day, l)} could not be read'), + l?.homeLoadFailedBody ?? + 'The stored day failed to load. Nothing was deleted — this is a ' + 'read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => (_loading = true, _failed = false)); + _load(); + }, + ), ] else ...[ if (_candidates.length >= 2) ...[ const SizedBox(height: S.x2), diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index aa3bd60d4..d430cfde8 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -470,6 +470,12 @@ class _SleepDetailState extends State with RevisionReload { SleepData? _d; bool _loading = true; String? _day; + + /// The last read threw. Kept apart from "no night": a read that failed + /// under a busy database must not tell someone with months of history that + /// the band recorded nothing, and on a day step it must not leave the + /// previous night drawn under the new date. + bool _failed = false; bool _saving = false; // an override write + its forced re-derive is in flight double? _scrub; // 0..1 across the night @@ -582,10 +588,13 @@ class _SleepDetailState extends State with RevisionReload { _correctable = correctable, _wholeNightKept = wholeNightKept, _loading = false, + _failed = false, )); } } catch (_) { - if (mounted && gen == _loadGen) setState(() => _loading = false); + if (mounted && gen == _loadGen) { + setState(() => (_loading = false, _failed = true)); + } } } @@ -597,6 +606,7 @@ class _SleepDetailState extends State with RevisionReload { _scrub = null; _overrideFailed = null; _loading = true; + _failed = false; }); _load(); } @@ -614,6 +624,31 @@ class _SleepDetailState extends State with RevisionReload { ]); } + if (_failed) { + // `_d` may still hold the night this screen stepped away from; only its + // day list is used, so the stepper stays and the day can be walked off. + final shown = _day ?? d.day; + return _frame(c, title, [ + ...dayNavRow(shown, d.days, _goDay), + const SizedBox(height: S.x2), + StatusCard( + shown == null + ? (l?.wellnessLoadFailedTitle ?? 'This could not be read') + : (l?.homeDayLoadFailedTitle(prettyDay(shown, l)) ?? + '${prettyDay(shown, l)} could not be read'), + l?.homeLoadFailedBody ?? + 'The stored day failed to load. Nothing was deleted — this is a ' + 'read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => (_loading = true, _failed = false)); + _load(); + }, + ), + ]); + } + if (!d.hasNight) { // "No night" also covers a night the user REJECTED outright (edge#248): // staging was skipped on purpose, so this day has no window at all — but @@ -651,7 +686,10 @@ class _SleepDetailState extends State with RevisionReload { ] else if (rejected) ...[ const SizedBox(height: S.x2), TextButton( - onPressed: _saving ? null : () => _clearWindow(rejectedDay), + // `_loading` too: mid-step this is still the previous day's undo. + onPressed: _saving || _loading + ? null + : () => _clearWindow(rejectedDay), child: Text( l?.sleepDetailUndoRejection ?? 'Undo — go back to automatic'), ), @@ -891,6 +929,10 @@ class _SleepDetailState extends State with RevisionReload { final vendor = source == 'vendor_staged'; final busy = _saving; + // Every button below acts on `day`, the night drawn. While a day step is + // loading that is still the night the user stepped away from, not the + // date the stepper reads, the same reason the naps button waits. + final locked = busy || _loading; return [ const SizedBox(height: S.x3), Surface( @@ -946,18 +988,18 @@ class _SleepDetailState extends State with RevisionReload { Wrap(spacing: S.x2, children: [ if (fallback && _wholeNightKept) TextButton( - onPressed: busy ? null : () => _confirmWindow(day), + onPressed: locked ? null : () => _confirmWindow(day), child: Text(l?.sleepDetailConfirmTimes ?? 'These times are right'), ), TextButton( - onPressed: busy ? null : () => _editWindow(day, t0, t1), + onPressed: locked ? null : () => _editWindow(day, t0, t1), child: Text(mine ? (l?.sleepDetailChangeTimes ?? 'Change the times') : (l?.sleepDetailSetTimesMyself ?? 'Set the times myself')), ), if (mine && _wholeNightKept) TextButton( - onPressed: busy ? null : () => _clearWindow(day), + onPressed: locked ? null : () => _clearWindow(day), child: Text(l?.sleepDetailBackToAutomatic ?? 'Back to automatic'), ), @@ -965,7 +1007,7 @@ class _SleepDetailState extends State with RevisionReload { // already have a reject action (sleep_nap source='rejected'); // a whole-night main-sleep session didn't (edge#248). TextButton( - onPressed: busy ? null : () => _rejectWindow(day), + onPressed: locked ? null : () => _rejectWindow(day), child: Text(l?.sleepDetailNotSleep ?? 'Not sleep'), ), ]), diff --git a/test/ui2_sleep_detail_load_failure_test.dart b/test/ui2_sleep_detail_load_failure_test.dart new file mode 100644 index 000000000..4fe450abd --- /dev/null +++ b/test/ui2_sleep_detail_load_failure_test.dart @@ -0,0 +1,145 @@ +// A read that throws on the Sleep tab used to look exactly like a day with no +// night: the first load fell through to "No night to show … wear the band +// overnight", and a failed day step left the previous night on screen under +// the new date, with its correction buttons still acting on that night. A +// failed read now ends in its own retryable card, and the same holds for the +// day timeline. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Three days on disk, none with a night. Throws on insight and timeline +/// reads while [failures] is above zero. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + void _maybeThrow() { + if (failures > 0) { + failures--; + throw const FormatException('database is locked'); + } + } + + @override + Future> getToday() async => { + 'status': {'today_day': '2026-05-22'}, + }; + + @override + Future> availableDays() async => + ['2026-05-22', '2026-05-20', '2026-05-19']; + + @override + Future> getDaySleepV2(String date) async => {}; + + @override + Future> getDayTimeline(String date) async { + _maybeThrow(); + return {}; + } + + @override + Future> getInsights() async { + _maybeThrow(); + return {}; + } + + @override + Future> getChart(String metric, + {int? from, int? to, Set signals = const {}}) async => + {}; + + @override + Future>> sleepWindows( + {int days = 60, String? before}) async => + []; +} + +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return; + } +} + +Future _pump(WidgetTester t, LocalRepository repo, Widget screen) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value(value: app, child: screen), + )); + await _settle(t); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('the Sleep screen', () { + testWidgets('a failed first read is not "no night", and can be retried', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo, const SleepDetail()); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('No night to show'), findsNothing); + + await t.tap(find.text('Try again')); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsNothing); + expect(find.text('No night to show'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets('a failed day step does not leave the old night on screen', + (t) async { + final repo = _FlakyRepo(0); + await _pump(t, repo, const SleepDetail()); + expect(find.text('No night to show'), findsOneWidget); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-19'); + await t.pump(); + await _settle(t); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('No night to show'), findsNothing); + // The stepper stays so the day can be walked off. + expect(find.byType(DayNav), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('the day timeline', () { + // Only the failure is driven here: a successful timeline read also goes + // to the journal, meal and medication tables, which this stub has none of. + testWidgets('a failed read shows a retryable card, not an empty day', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo, const DayTimelineScreen()); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); + }); +} From 43f872943e5c77d7d03b9aa6dad63b6cee97f2c2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:56:31 +0530 Subject: [PATCH 34/72] recovery's nerd stats opened today even for a past day the investigate row dropped the day recovery was opened for, so investigate fell back to the newest derived day and its inputs didn't match the score on screen. it passes d.day now, the same way sleep and beats do; today still passes null. (cherry picked from commit 7cf52dbff2eb6caba333cf6c246ed3044dc2d97b) --- lib/ui2/screens/readiness_detail.dart | 5 +++- ...readiness_detail_investigate_day_test.dart | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 test/readiness_detail_investigate_day_test.dart diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 5b7058b8e..cddcba131 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -356,7 +356,10 @@ class _ReadinessDetailState extends State { : Surface(child: _history(c, d)), ), const SizedBox(height: S.x5), - investigateRow(c, () => go(c, const Investigate('readiness'))), + // A past day opened from Home has to carry its day into Nerd stats; + // without it Investigate falls back to the newest derived day and its + // inputs stop matching the score above. d.day is null for today. + investigateRow(c, () => go(c, Investigate('readiness', day: d.day))), ], ]); } diff --git a/test/readiness_detail_investigate_day_test.dart b/test/readiness_detail_investigate_day_test.dart new file mode 100644 index 000000000..59706e653 --- /dev/null +++ b/test/readiness_detail_investigate_day_test.dart @@ -0,0 +1,25 @@ +// Recovery opened for a past day (Home's day stepper, or the "See what was +// missing" card) showed that day's score, but Nerd stats opened Investigate +// with no day, so it read the newest derived day instead. Sleep and Beats +// already pass their day through; this pins Recovery to the same contract. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('every detail screen hands its day to Investigate', () { + final src = { + 'readiness_detail': "Investigate('readiness', day: d.day)", + 'sleep_detail': "Investigate('sleep', day:", + 'beats': "Investigate('hrv', day:", + }; + for (final e in src.entries) { + final text = File('lib/ui2/screens/${e.key}.dart').readAsStringSync(); + expect(text, contains(e.value), + reason: '${e.key} opens Nerd stats on the newest day, not its own'); + } + expect( + File('lib/ui2/screens/readiness_detail.dart').readAsStringSync(), + isNot(contains("const Investigate('readiness')"))); + }); +} From 61dcbebd5eb9f17ead9e1dfcbeab3c9aea8820d5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:28 +0530 Subject: [PATCH 35/72] sleep plan and recovery detail said not enough nights while the rollup was being rebuilt when the cross-day rollup is withheld (after an update that changes how days are computed, or when it is over a week old) getInsights returns only the reason. home shows that the insights are being rebuilt, but the sleep plan fell through to 'needs enough nights' and 'nothing recorded says why', and the recovery detail said ranking takes about two weeks of nights under a scored number. both now show the withheld reason with a sync button. (cherry picked from commit 5543037b00dd88e1402d7c8c7425c8b5450e4f8a) --- lib/ui2/screens/readiness_detail.dart | 25 +++++++++++++------ lib/ui2/screens/wellness_screen.dart | 23 ++++++++++++++--- test/recovery_headline_ui_test.dart | 29 ++++++++++++++++++++++ test/ui2_wellness_recovery_paint_test.dart | 24 ++++++++++++++++++ 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index cddcba131..3e17a1a8f 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/metric.dart'; import '../../models/payloads.dart' show todayHeadlineOf; import '../ui2.dart'; +import 'driver_breakdown.dart' show driverAbsenceCard; import 'home_screen.dart'; import 'investigate.dart'; import 'metric_detail.dart'; @@ -56,6 +57,11 @@ class ReadinessData { /// Home shows beside this number. final Map head; + /// Why `getInsights()` withheld the cross-day rollup, or null when it did + /// not (`staleReasonOf`). A withheld rollup is why the breakdown is empty, + /// and "takes about two weeks of nights" is the wrong reason to give then. + final Map? insightsStale; + bool get provisional => head['recovery_state'] == 'provisional'; /// The past day this was read for, or null for today. Set only by @@ -71,6 +77,7 @@ class ReadinessData { this.heldOverNight, this.series = const [], this.absentDiag, + this.insightsStale, }); /// A past day, as Home's day switcher shows it: the stored score for @@ -124,6 +131,7 @@ class ReadinessData { if (e is Map) e.cast(), ], inputsUsed: (v['inputs_used'] as num?)?.toInt() ?? 0, + insightsStale: staleReasonOf(cd), heldOverNight: heldOverNightOf(today), series: denseDays(pointsOf(chart), 90), // Only read when there is nothing to explain away — a scored day has no @@ -333,13 +341,16 @@ class _ReadinessDetailState extends State { ] else if (v != null && d.day == null) Section( l?.readinessDetailWhatWentIntoIt ?? 'What went into it', - StatusCard( - l?.readinessDetailNoBreakdownTitle ?? 'No breakdown yet', - l?.readinessDetailNoBreakdownBody ?? - 'Ranking each input against your own history takes about two ' - 'weeks of nights.', - icon: LucideIcons.listTree, - ), + d.insightsStale != null + ? driverAbsenceCard(c, + stale: d.insightsStale, onSync: syncOf(c)) + : StatusCard( + l?.readinessDetailNoBreakdownTitle ?? 'No breakdown yet', + l?.readinessDetailNoBreakdownBody ?? + 'Ranking each input against your own history takes ' + 'about two weeks of nights.', + icon: LucideIcons.listTree, + ), ), // The header used to say "Last 90 days" over a chart of five points. diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index 931ff5da8..fe5c01b73 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -34,7 +34,14 @@ import '../ui2.dart'; import 'calm_breathing.dart'; import 'driver_breakdown.dart'; import 'cycle_screen.dart'; -import 'home_screen.dart' show envValue, metricOf, weekdayShortName; +import 'home_screen.dart' + show + envValue, + metricOf, + staleInsightsCard, + staleReasonOf, + syncOf, + weekdayShortName; import 'journal_compose.dart'; import 'start_card.dart'; import 'metric_detail.dart' show detailScaffold; @@ -496,6 +503,12 @@ class _WellnessScreenState extends State with RevisionReload { final napMin = _reading(coachMap?['nap_credit_min']); final strainMin = _reading(coachMap?['strain_bonus_min']); final debtH = _nested(_insights, 'sleep_debt', 'debt_hours'); + // A WITHHELD rollup (algo-version change, unstamped, or over a week old) + // arrives as `{'stale': reason}` with every key above missing. The + // cold-start copy below would then tell a user with months of nights that + // there are not enough of them yet, one tab over from Home saying the + // insights are being rebuilt. + final stale = staleReasonOf(_insights); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -526,7 +539,9 @@ class _WellnessScreenState extends State with RevisionReload { // usual, no direction, no size, and no way to tell a move that // mattered from one inside the noise — all of which were already // being written on every derive and read by nothing. - _drivers.isEmpty + _drivers.isEmpty && stale != null + ? driverAbsenceCard(c, stale: stale, onSync: syncOf(c)) + : _drivers.isEmpty ? StatusCard( l?.wellnessNoDriversTitle ?? 'No recovery breakdown yet', whyFromNote(metricOf(_stress['readiness']).note) ?? @@ -539,7 +554,9 @@ class _WellnessScreenState extends State with RevisionReload { ), Section( l?.wellnessSleepNeedTonight ?? 'Sleep need tonight', - needSec == null + needSec == null && stale != null + ? staleInsightsCard(stale, syncOf(c), l)! + : needSec == null // The coach's own reason for the absent need — it names the // input that is actually missing. "Not enough of them yet" named // nothing, and was printed for every cause the estimator has. diff --git a/test/recovery_headline_ui_test.dart b/test/recovery_headline_ui_test.dart index 780b60ba4..6357b2623 100644 --- a/test/recovery_headline_ui_test.dart +++ b/test/recovery_headline_ui_test.dart @@ -142,6 +142,35 @@ void main() { expect(find.text('Finishing up'), findsNothing); }); + // A withheld rollup empties the breakdown while the headline still scores + // off day_result. "Takes about two weeks of nights" is then the wrong + // reason, under a number that proves the nights exist. + testWidgets('withheld rollup: the breakdown says why, not cold start', + (t) async { + await pumpDetail( + t, + const ReadinessData( + head: {'recovery_state': 'final', 'recovery': 62}, + readiness: Metric(value: 62, confidence: .8), + insightsStale: {'kind': 'algo_version'})); + await t.pumpAndSettle(); + expect(find.text('No breakdown to show'), findsOneWidget); + expect(find.textContaining('changed in the last update'), findsOneWidget); + expect(find.text('No breakdown yet'), findsNothing); + expect(find.textContaining('two weeks of nights'), findsNothing); + }); + + testWidgets('no withheld rollup: the cold-start card stays', (t) async { + await pumpDetail( + t, + const ReadinessData( + head: {'recovery_state': 'final', 'recovery': 62}, + readiness: Metric(value: 62, confidence: .8))); + await t.pumpAndSettle(); + expect(find.text('No breakdown yet'), findsOneWidget); + expect(find.text('No breakdown to show'), findsNothing); + }); + test('the shared line (Health reads it too) per state', () { expect(recoveryStateLine({'recovery_state': 'night_in_progress'}, null), 'Sleeping…'); diff --git a/test/ui2_wellness_recovery_paint_test.dart b/test/ui2_wellness_recovery_paint_test.dart index 500695fca..f4508e8ff 100644 --- a/test/ui2_wellness_recovery_paint_test.dart +++ b/test/ui2_wellness_recovery_paint_test.dart @@ -292,6 +292,30 @@ void main() { }, timeout: const Timeout(Duration(seconds: 60))); } } + + // A WITHHELD rollup (`getInsights` -> `{'stale': reason}`) after an update + // that changed how days are computed. Home says the insights are being + // rebuilt; this tab used to say there were not enough nights yet and that + // nothing recorded why tonight has no need. + testWidgets('withheld rollup: says it is being rebuilt, not cold start', + (t) async { + await _openRecovery( + t, + dark: false, + scale: 1.0, + repo: _Repo(insights: const { + 'stale': {'kind': 'algo_version', 'algo_version': 1}, + }), + ); + + expect(find.text('No breakdown to show'), findsOneWidget); + expect(find.text('Your cross-day insights are being rebuilt'), + findsOneWidget); + expect(find.text('No recovery breakdown yet'), findsNothing); + expect(find.text('No sleep need yet'), findsNothing); + expect(find.textContaining('enough nights'), findsNothing); + _expectCardPainted(t); + }, timeout: const Timeout(Duration(seconds: 60))); } /// TWO framework-internal assertions this harness causes and the screen does From 0bfd239ed98a26200a455ca13b34fe95c44cb9a2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:57:23 +0530 Subject: [PATCH 36/72] sleep efficiency verdict said 'points' in english in every language the efficiency row glued a hardcoded '12 points' in front of the localized lower/higher phrase, so a german reader got '12 points niedriger als ueblich'. the unit now comes from a new sleepDetailPointsDelta string in every locale. (cherry picked from commit cbbb4b2b6f7d88233d10cfd057c3f0a775a79dc4) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 9 ++++++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/sleep_detail.dart | 9 ++++++-- test/sleep_efficiency_delta_l10n_test.dart | 26 ++++++++++++++++++++++ 9 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/sleep_efficiency_delta_l10n_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5ad20e3ec..6846f0091 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1749,6 +1749,7 @@ "sleepDetailAsleepWhileInBed": "Schlafanteil im Bett", "sleepDetailLowerThanUsual": "niedriger als üblich", "sleepDetailHigherThanUsual": "höher als üblich", + "sleepDetailPointsDelta": "{n} Prozentpunkte", "sleepDetailFellAsleep": "Eingeschlafen", "sleepDetailEarlierThanUsual": "früher als üblich", "sleepDetailLaterThanUsual": "später als üblich", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bad0b2989..d9fe2884f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8732,6 +8732,15 @@ "@sleepDetailHigherThanUsual": { "description": "Verdict phrase: sleep efficiency was higher than usual." }, + "sleepDetailPointsDelta": "{n} points", + "@sleepDetailPointsDelta": { + "description": "Verdict prefix: how far sleep efficiency was from the usual median, in percentage points; followed by the lower/higher-than-usual phrase.", + "placeholders": { + "n": { + "type": "String" + } + } + }, "sleepDetailFellAsleep": "Fell asleep", "@sleepDetailFellAsleep": { "description": "Row label in the 'against your usual' comparison: sleep onset time." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d93850b00..c705d6664 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1709,6 +1709,7 @@ "sleepDetailAsleepWhileInBed": "Dormido mientras estabas en cama", "sleepDetailLowerThanUsual": "más bajo de lo habitual", "sleepDetailHigherThanUsual": "más alto de lo habitual", + "sleepDetailPointsDelta": "{n} puntos", "sleepDetailFellAsleep": "Te dormiste", "sleepDetailEarlierThanUsual": "más temprano de lo habitual", "sleepDetailLaterThanUsual": "más tarde de lo habitual", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d01085608..038aa8ea6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1699,6 +1699,7 @@ "sleepDetailAsleepWhileInBed": "Endormi pendant le temps au lit", "sleepDetailLowerThanUsual": "plus bas que d'habitude", "sleepDetailHigherThanUsual": "plus haut que d'habitude", + "sleepDetailPointsDelta": "{n} points", "sleepDetailFellAsleep": "Endormissement", "sleepDetailEarlierThanUsual": "plus tôt que d'habitude", "sleepDetailLaterThanUsual": "plus tard que d'habitude", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 988f76f41..6bf643340 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1699,6 +1699,7 @@ "sleepDetailAsleepWhileInBed": "बिस्तर पर रहते हुए सोया हुआ", "sleepDetailLowerThanUsual": "सामान्य से कम", "sleepDetailHigherThanUsual": "सामान्य से ज़्यादा", + "sleepDetailPointsDelta": "{n} अंक", "sleepDetailFellAsleep": "नींद आई", "sleepDetailEarlierThanUsual": "सामान्य से जल्दी", "sleepDetailLaterThanUsual": "सामान्य से देर से", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 40e3ac212..249ad7539 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4013,6 +4013,7 @@ "sleepDetailAsleepWhileInBed": "Сон во время нахождения в постели", "sleepDetailLowerThanUsual": "ниже обычного", "sleepDetailHigherThanUsual": "выше обычного", + "sleepDetailPointsDelta": "на {n} п.п.", "sleepDetailFellAsleep": "Засыпание", "sleepDetailEarlierThanUsual": "раньше обычного", "sleepDetailLaterThanUsual": "позже обычного", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0cfcab012..a38a17528 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1699,6 +1699,7 @@ "sleepDetailAsleepWhileInBed": "卧床期间的入睡比例", "sleepDetailLowerThanUsual": "比平时低", "sleepDetailHigherThanUsual": "比平时高", + "sleepDetailPointsDelta": "{n} 个百分点", "sleepDetailFellAsleep": "入睡时间", "sleepDetailEarlierThanUsual": "比平时早", "sleepDetailLaterThanUsual": "比平时晚", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index d430cfde8..5371b8f5c 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -140,7 +140,12 @@ int? _noonOf(String? day) => day == null } String _pct(double v) => '${v.round()}%'; -String _pts(double v) => '${v.round()} points'; + +/// The efficiency gap in percentage points, in the reader's language. It is +/// glued in front of the localized lower/higher phrase, so an English unit +/// here left every other locale reading a half-English verdict. +String sleepEfficiencyDelta(AppLocalizations? l, double v) => + l?.sleepDetailPointsDelta('${v.round()}') ?? '${v.round()} points'; /// SLP-13 — the three staged figures of a night, as the intervals they are. /// @@ -1841,7 +1846,7 @@ class _SleepDetailState extends State with RevisionReload { low: l?.sleepDetailLowerThanUsual ?? 'lower than usual', high: l?.sleepDetailHigherThanUsual ?? 'higher than usual', fmt: _pct, - dfmt: _pts, + dfmt: (v) => sleepEfficiencyDelta(l, v), )); } diff --git a/test/sleep_efficiency_delta_l10n_test.dart b/test/sleep_efficiency_delta_l10n_test.dart new file mode 100644 index 000000000..097867ec9 --- /dev/null +++ b/test/sleep_efficiency_delta_l10n_test.dart @@ -0,0 +1,26 @@ +// The efficiency row of the sleep comparison put an English "N points" in +// front of the localized lower/higher phrase, so a German reader saw +// "12 points niedriger als üblich". The unit now comes from the locale. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; + +void main() { + test('english keeps points', () { + final l = lookupAppLocalizations(const Locale('en')); + expect(sleepEfficiencyDelta(l, 11.6), '12 points'); + expect(sleepEfficiencyDelta(null, 11.6), '12 points'); + }); + + for (final code in ['de', 'es', 'ru', 'hi', 'zh']) { + test('$code verdict has no english unit', () { + final l = lookupAppLocalizations(Locale(code)); + final s = sleepEfficiencyDelta(l, 11.6); + expect(s, contains('12')); + expect(s, isNot(contains('points'))); + }); + } +} From 12e4f784ecd7b2e20ad03499407ca854d8cca8f9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:02 +0530 Subject: [PATCH 37/72] naps screen spun forever when one of its reads threw the naps screen awaited its three reads with no error handling, so a busy or locked database left it on a spinner for good, with no message and no retry. a failed read now shows the same could-not-be-read card with a try again button the sleep plan uses. the reload after a nap edit was also outside the edit's guard, so a throw there escaped and the screen kept the pre-edit list; it now lands on that card, and the before/after check no longer compares against a list that was never re-read. (cherry picked from commit 032003e39615b4a96f74dae9bb316d8275190183) --- lib/ui2/screens/naps.dart | 63 ++++++++++++++++++-- test/ui2_naps_load_failure_test.dart | 88 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 test/ui2_naps_load_failure_test.dart diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index c3e97e442..d8a6bda96 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -111,6 +111,10 @@ class _NapsScreenState extends State { bool _busy = false; String? _failed; + /// The last read threw. Shown as a retryable card: uncaught, `_d` stayed + /// null and the screen sat on its spinner with no way out. + bool _loadFailed = false; + @override void initState() { super.initState(); @@ -118,18 +122,41 @@ class _NapsScreenState extends State { _load(); } - Future _load() async { + /// False when a read threw — a busy or locked database is enough. [_edit] + /// needs that answer: comparing against a list that was never re-read would + /// call a stale screen the engine's verdict. + Future _load() async { final repo = repoOf(context); - if (repo == null) return; + if (repo == null) return true; final token = ++_loadToken; - final d = await NapsData.load(repo, want: _day); - if (mounted && token == _loadToken) setState(() => _d = d); + try { + final d = await NapsData.load(repo, want: _day); + if (mounted && token == _loadToken) { + setState(() { + _d = d; + _loadFailed = false; + }); + } + return true; + } catch (_) { + if (mounted && token == _loadToken) setState(() => _loadFailed = true); + return false; + } + } + + void _retry() { + setState(() { + _loadFailed = false; + _d = null; + }); + _load(); } void _goDay(String day) { setState(() { _day = day; _d = null; + _loadFailed = false; }); _load(); } @@ -159,10 +186,12 @@ class _NapsScreenState extends State { } finally { if (mounted) setState(() => _busy = false); } - if (mounted) await _load(); + final reloaded = mounted && await _load(); if (!mounted) return; final after = _d?.naps.map((n) => n['start']).toList(); - if (failed != null || '$before' == '$after') { + // A reload that threw left `_d` as the pre-edit list, so before == after + // says nothing about the edit; the read-failed card covers that case. + if (failed != null || (reloaded && '$before' == '$after')) { final l = AppLocalizations.of(context); setState(() => _failed = failed ?? l?.napsNotReanalysed ?? @@ -255,6 +284,28 @@ class _NapsScreenState extends State { final p = P.of(c); final l = AppLocalizations.of(c); final d = _d; + if (_loadFailed) { + return detailScaffold(c, l?.napsTitle ?? 'Naps', [ + const SizedBox(height: S.x4), + StatusCard( + l?.wellnessLoadFailedTitle ?? 'This could not be read', + l?.wellnessLoadFailedBody ?? + 'Reading your stored data failed. Nothing was deleted — this is ' + 'a read that went wrong, not missing data.', + fix: l?.homeTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: _retry, + ), + // An edit can land on disk and then fail its re-read; what the edit + // itself said still has to reach the screen. + if (_failed != null) ...[ + const SizedBox(height: S.x3), + StatusCard(l?.napsNotAppliedTitle ?? 'That has not been applied', + _failed!, + icon: LucideIcons.triangleAlert), + ], + ]); + } if (d == null) { return detailScaffold(c, l?.napsTitle ?? 'Naps', const [ Padding( diff --git a/test/ui2_naps_load_failure_test.dart b/test/ui2_naps_load_failure_test.dart new file mode 100644 index 000000000..fb8506dca --- /dev/null +++ b/test/ui2_naps_load_failure_test.dart @@ -0,0 +1,88 @@ +// The Naps screen makes three reads (available days, the day's naps, the +// nap edits) and used to await them with no error handling. A read that +// threw — a busy or locked database is enough — left it on its spinner for +// good, with no message and no way to retry. It now ends in a retryable card. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Throws on the first [failures] nap reads, then answers with a judged day +/// that had no naps. +class _FlakyRepo extends LocalRepository { + _FlakyRepo(this.failures); + int failures; + + @override + Future> availableDays() async => const ['2026-10-01']; + + @override + Future> getDayNaps(String date) async { + if (failures > 0) { + failures--; + throw StateError('database is locked'); + } + return const {'naps': [], 'nap_min': 0}; + } +} + +/// `_load` reaches sqflite on a real event loop, so real time has to pass +/// between pumps. Polls until the spinner is gone; false if it never goes. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) return true; + } + return false; +} + +Future _pump(WidgetTester t, LocalRepository repo) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const NapsScreen(day: '2026-10-01'), + ), + )); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a read that throws shows a retryable card, not a spinner', + (t) async { + final repo = _FlakyRepo(1); + await _pump(t, repo); + + expect(await _settle(t), isTrue, + reason: 'the Naps screen never left its spinner'); + expect(find.text('This could not be read'), findsOneWidget); + + await t.tap(find.text('Try again')); + await t.pump(); + expect(await _settle(t), isTrue); + expect(find.text('This could not be read'), findsNothing); + expect(find.text('No naps on this day'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); +} From 321d6f1e871c935f826d02f799468c72a8dc6661 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:00 +0530 Subject: [PATCH 38/72] a failed workout save showed under a green 'times updated' check every failure in the log/retime form wrote its message into the same field as the unscored-saved confirmation, so a refused retime on a pruned session, a db error, an overlap refusal or a stale suggestion all appeared as 'Times updated' / 'Workout logged' with a check while nothing was saved. failures now go in their own field and render with the 'That window will not save' warning card. (cherry picked from commit 7b5f7d748e6c8ea225b7136a3f63751699caa0a8) --- lib/ui2/screens/log_workout.dart | 18 ++++++++--- test/log_workout_test.dart | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart index 0bab2f642..98957ca83 100644 --- a/lib/ui2/screens/log_workout.dart +++ b/lib/ui2/screens/log_workout.dart @@ -163,6 +163,10 @@ class _LogWorkoutState extends State { List _spans = const []; bool _saving = false; String? _wrote; + // Kept apart from [_wrote]: a refused or failed save must never be drawn + // under the "Times updated" / "Workout logged" check, since nothing was + // banked. + String? _failed; ActivitySuggestion? _suggestion; @override @@ -285,6 +289,7 @@ class _LogWorkoutState extends State { setState(() { _saving = true; _wrote = null; + _failed = null; }); try { if (_suggestion case final suggestion?) { @@ -322,7 +327,7 @@ class _LogWorkoutState extends State { if (!mounted) return; setState(() { _saving = false; - _wrote = l?.logWorkoutImportedHrGone ?? + _failed = l?.logWorkoutImportedHrGone ?? 'Heart rate for this workout is no longer stored, so the band ' 'cannot score it.'; }); @@ -368,18 +373,18 @@ class _LogWorkoutState extends State { // reload must not leave the save button permanently busy. } finally { if (mounted) { - setState(() { _saving = false; _wrote = e.message; }); + setState(() { _saving = false; _failed = e.message; }); } } } on ManualWindowException catch (e) { if (mounted) { - setState(() { _saving = false; _wrote = e.error.message; }); + setState(() { _saving = false; _failed = e.error.message; }); } } catch (_) { if (mounted) { setState(() { _saving = false; - _wrote = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; + _failed = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; }); } } @@ -442,6 +447,11 @@ class _LogWorkoutState extends State { l?.logWorkoutWindowInvalidTitle ?? 'That window will not save', bad.message, icon: LucideIcons.triangleAlert) + else if (_failed != null) + StatusCard( + l?.logWorkoutWindowInvalidTitle ?? 'That window will not save', + _failed!, + icon: LucideIcons.triangleAlert) else if (_wrote != null) StatusCard( retime diff --git a/test/log_workout_test.dart b/test/log_workout_test.dart index 7fbbe1c5a..53a28ae44 100644 --- a/test/log_workout_test.dart +++ b/test/log_workout_test.dart @@ -15,10 +15,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/manual_session.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/activity/catalogue.dart'; import 'package:openstrap_edge/ui2/screens/log_workout.dart'; import 'package:openstrap_edge/ui2/screens/detected_activities.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:provider/provider.dart'; + +/// A save that the repository refuses: a retime over a pruned substrate, or +/// any write that throws. +class _RefusingRepo extends LocalRepository { + final Object error; + _RefusingRepo(this.error); + @override + Future> setWorkoutWindow(String id, + {required int startTs, required int endTs}) async => + throw error; +} /// A real phone, and tall enough that nothing under test is below the fold — /// the default 800x600 harness hides the very controls these tests are about. @@ -190,6 +204,46 @@ void main() { expect(find.text('Activity'), findsNothing); }); + for (final (name, error, body) in [ + ( + 'a retime the score cannot follow', + const ManualWindowException(ManualWindowError.scoreNotRecomputable), + ManualWindowError.scoreNotRecomputable.message, + ), + ('a write that throws', StateError('db'), 'Could not save that — try again.'), + ]) { + testWidgets('$name is shown as a refusal, never under a check', (t) async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _RefusingRepo(error); + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: LogWorkout( + sessionId: 'manual:123', + now: _now, + start: _start, + end: _end, + spans: const [], + activity: activityByName('running'), + title: 'Fix the times', + ), + ), + )); + await t.pumpAndSettle(); + await t.tap(find.text('Save the new times')); + await t.pumpAndSettle(); + expect(find.text(body), findsOneWidget); + expect(find.text('That window will not save'), findsOneWidget); + expect(find.text('Times updated'), findsNothing, + reason: 'nothing was saved'); + }); + } + testWidgets('picking an end time before the start rolls to the next day', ( t, ) async { From e245b773bb4c7d3d374a5739cd786ce7f73efae3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:57:38 +0530 Subject: [PATCH 39/72] the sleep plan's debt link opened a second sleep screen the plan only renders as the sleep tab's footer, so 'see what last night cost you' pushed a copy of the screen already underneath, with the tonight section the tab leaves out. it scrolls back up to the night now. (cherry picked from commit 962b899cf5e8d3a0dc0565775abf3980fbb09446) --- lib/ui2/screens/wellness_screen.dart | 20 ++++++++++++++++---- test/ui2_wellness_recovery_paint_test.dart | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index fe5c01b73..301fac188 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -45,7 +45,6 @@ import 'home_screen.dart' import 'journal_compose.dart'; import 'start_card.dart'; import 'metric_detail.dart' show detailScaffold; -import 'sleep_detail.dart'; class WellnessScreen extends StatefulWidget { /// Only the sleep plan (need, debt, bedtime, what charged and drained you), @@ -528,9 +527,22 @@ class _WellnessScreenState extends State with RevisionReload { 'tonight\'s is ${_hm(needSec / 60)}.', l?.wellnessSeeWhatLastNightCost ?? 'See what last night cost you', color: C.indigo, - onTap: () => Navigator.of(c).push( - MaterialPageRoute(builder: (_) => const SleepDetail()), - ), + // This plan only ever renders as the Sleep tab's footer, under + // last night. Pushing SleepDetail opened a second copy of the + // screen already underneath, so go back up to the night instead. + onTap: () { + final pos = Scrollable.maybeOf(c)?.position; + if (pos == null) return; + // Reduced motion hands back a zero duration, and a scroll + // animation asserts on one — jump instead. + final d = motion(c, Motion.slow); + if (d == Duration.zero) { + pos.jumpTo(pos.minScrollExtent); + } else { + unawaited(pos.animateTo(pos.minScrollExtent, + duration: d, curve: Curves.easeOut)); + } + }, ), ), Section( diff --git a/test/ui2_wellness_recovery_paint_test.dart b/test/ui2_wellness_recovery_paint_test.dart index f4508e8ff..7b170344e 100644 --- a/test/ui2_wellness_recovery_paint_test.dart +++ b/test/ui2_wellness_recovery_paint_test.dart @@ -316,6 +316,23 @@ void main() { expect(find.textContaining('enough nights'), findsNothing); _expectCardPainted(t); }, timeout: const Timeout(Duration(seconds: 60))); + + // The plan is the Sleep tab's footer, so its debt link used to push a whole + // second SleepDetail over the one already on screen — same night, plus the + // Tonight section the tab leaves out on purpose. It scrolls back up instead. + testWidgets('the debt link stays on the Sleep tab instead of pushing it again', + (t) async { + await _openRecovery(t, dark: false, scale: 1.0, repo: _Repo()); + + final link = find.text('See what last night cost you'); + expect(link, findsOneWidget); + await t.tap(link); + await _frames(t); + + expect(find.byType(SleepDetail), findsNothing); + expect(find.text('See what last night cost you'), findsOneWidget); + expect(find.byType(BackButton), findsNothing); + }, timeout: const Timeout(Duration(seconds: 60))); } /// TWO framework-internal assertions this harness causes and the screen does From caa357f7f39e60c3b15955a5aa78eb987040d5b0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:56 +0530 Subject: [PATCH 40/72] confirming a detected workout showed the raw exception text when the write was refused (say the detected bout overlaps a workout I had already logged), the 'could not finish' card printed 'ManualWindowException: that overlaps...' and any other failure printed its class name or a raw database error. the card now shows the refusal's own reason, and a plain localized 'could not save' for anything else. (cherry picked from commit 238ed5264b897a03664a7da8df28cf66ac3d325c) --- lib/ui2/screens/detected_activities.dart | 13 ++++++++++-- test/detected_activities_ui_test.dart | 26 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index b414112db..ba0be02fc 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../l10n/app_localizations.dart'; +import '../../compute/manual_session.dart' + show ManualWindowErrorMessage, ManualWindowException; import '../../compute/nap_edits.dart'; import '../../models/activity_suggestion.dart'; import '../../theme/theme_switcher.dart' show themedRoute; @@ -77,6 +79,7 @@ class _DetectedActivitiesScreenState extends State if (_busy) return; final repo = repoOf(context); if (repo == null) return; + final l = AppLocalizations.of(context); setState(() => _busy = true); String? error; try { @@ -90,8 +93,14 @@ class _DetectedActivitiesScreenState extends State workoutType: activityByName(s.sport)?.typeKey ?? 'other', ); } - } catch (e) { - error = e.toString(); + } on ActivityReviewException catch (e) { + error = e.message; + } on ManualWindowException catch (e) { + // toString() carries the class name; the card shows only the reason. + error = e.error.message; + } catch (_) { + // Anything else (database, health export) has no copy fit for a person. + error = l?.logWorkoutCouldNotSave ?? 'Could not save that — try again.'; } if (!mounted) return; setState(() => _busy = false); diff --git a/test/detected_activities_ui_test.dart b/test/detected_activities_ui_test.dart index b09492d6b..e6cb18212 100644 --- a/test/detected_activities_ui_test.dart +++ b/test/detected_activities_ui_test.dart @@ -30,6 +30,7 @@ class _Repo extends LocalRepository { proposal('workout', ActivityKind.workout), ]; bool fail = false; + Object? confirmError; String? savedType; @override Future> pendingActivities() async { @@ -57,6 +58,7 @@ class _Repo extends LocalRepository { int? endTs, String? workoutType, }) async { + if (confirmError case final e?) throw e; if (items.singleWhere((i) => i.id == s.id).revision != s.revision) { throw const ActivityReviewException( 'The proposal changed. Review the new times.', @@ -183,6 +185,30 @@ void main() { expect(repo.items.single.id, 'nap'); }); + testWidgets('a refused confirm shows the reason, not the exception class', ( + t, + ) async { + final repo = await pump(t); + repo.confirmError = const ManualWindowException( + ManualWindowError.overlapsExisting, + ); + await t.tap(find.text('Confirm').last); + await t.pumpAndSettle(); + expect( + find.text('That overlaps a workout already in your log.'), + findsOneWidget, + ); + expect(find.textContaining('ManualWindowException'), findsNothing); + expect(repo.items, hasLength(2)); + + repo.confirmError = StateError('database is locked'); + await t.tap(find.text('Confirm').last); + await t.pumpAndSettle(); + expect(find.text('Could not save that — try again.'), findsOneWidget); + expect(find.textContaining('StateError'), findsNothing); + expect(find.textContaining('database is locked'), findsNothing); + }); + testWidgets('home card stays up when the count fails to load', (t) async { await pump(t, fail: true, home: const Scaffold(body: DetectedActivitiesCard())); expect(find.text('Detected activities'), findsOneWidget); From 2b37d4309b9efb46af5ae81aca373fcbdfa96adf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:59:37 +0530 Subject: [PATCH 41/72] a failed cycle read showed the tab as if tracking were off the catch in the cycle tab swapped in an empty CycleData, which is enabled: false, so a read that threw rendered the 'cycle tracking is off' opt-in card and her logs and prediction looked gone. the tab now keeps a separate failed flag and shows the same could-not-read card with a retry that the health tabs use. (cherry picked from commit f7678fda377e4180910f7f8262cee410b42296dc) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/cycle_screen.dart | 35 +++++++++++++-- test/ui2_cycle_read_failed_test.dart | 64 ++++++++++++++++++++++++++++ 9 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 test/ui2_cycle_read_failed_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6846f0091..c42d5748b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1279,6 +1279,7 @@ "healthTryAgain": "Erneut versuchen", "healthWhatVitals": "Vitalwerte", "healthWhatLabResults": "Laborergebnisse", + "cycleWhatCycleData": "Zyklusdaten", "healthMeasuresUnit": "Messwerte", "healthRowRestingHr": "Ruhepuls", "healthRowHrv": "HRV", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9fe2884f..9c75b9512 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6320,6 +6320,10 @@ "@healthWhatLabResults": { "description": "Noun 'lab results' plugged into the read-failure title" }, + "cycleWhatCycleData": "cycle data", + "@cycleWhatCycleData": { + "description": "Noun 'cycle data' plugged into the read-failure title on the Cycle tab" + }, "healthMeasuresUnit": "measures", "@healthMeasuresUnit": { "description": "Noun 'measures' — used both as the read-failure title noun and as the Explore consistency-tile unit" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c705d6664..8f5c9e26b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1244,6 +1244,7 @@ "healthTryAgain": "Intentar de nuevo", "healthWhatVitals": "vitales", "healthWhatLabResults": "resultados de laboratorio", + "cycleWhatCycleData": "datos del ciclo", "healthMeasuresUnit": "medidas", "healthRowRestingHr": "Frecuencia cardíaca en reposo", "healthRowHrv": "VFC", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 038aa8ea6..0ec3aadea 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1244,6 +1244,7 @@ "healthTryAgain": "Réessayer", "healthWhatVitals": "vitaux", "healthWhatLabResults": "résultats de laboratoire", + "cycleWhatCycleData": "données de cycle", "healthMeasuresUnit": "mesures", "healthRowRestingHr": "Fréquence cardiaque au repos", "healthRowHrv": "VFC", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6bf643340..6ec970a27 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1244,6 +1244,7 @@ "healthTryAgain": "फिर से कोशिश करें", "healthWhatVitals": "जीवन संकेत", "healthWhatLabResults": "लैब परिणाम", + "cycleWhatCycleData": "चक्र डेटा", "healthMeasuresUnit": "माप", "healthRowRestingHr": "आराम के समय हृदय गति", "healthRowHrv": "एचआरवी", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 249ad7539..70e2f687f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2867,6 +2867,7 @@ "healthTryAgain": "Повторить", "healthWhatVitals": "показатели", "healthWhatLabResults": "результаты анализов", + "cycleWhatCycleData": "данные цикла", "healthMeasuresUnit": "измерения", "healthRowRestingHr": "Пульс в покое", "healthRowHrv": "ВСР", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a38a17528..86bcdd66c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1244,6 +1244,7 @@ "healthTryAgain": "重试", "healthWhatVitals": "生命体征", "healthWhatLabResults": "化验结果", + "cycleWhatCycleData": "周期数据", "healthMeasuresUnit": "指标", "healthRowRestingHr": "静息心率", "healthRowHrv": "心率变异性", diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index e0fadce0b..f9f18cdfe 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -186,7 +186,12 @@ class CycleData { /// screen's own `ListView` the way the other four tabs do. class CycleTab extends StatefulWidget { final CycleData? data; - const CycleTab({super.key, this.data}); + + /// Replaces the repository read, so a test can make it throw. + @visibleForTesting + final Future Function()? loader; + + const CycleTab({super.key, this.data, this.loader}); @override State createState() => _CycleTabState(); @@ -195,6 +200,11 @@ class CycleTab extends StatefulWidget { class _CycleTabState extends State with RevisionReload { CycleData? _d; + /// The last read threw. Kept apart from [_d] because an empty `CycleData` + /// is `enabled: false`, and rendering that would tell her tracking is off + /// and offer to turn it on, when it is on and only the read went wrong. + bool _failed = false; + /// The symptom look-back is folded away by default — the chips above it are /// what she opened the tab to tap. bool _showShape = false; @@ -225,10 +235,11 @@ class _CycleTabState extends State with RevisionReload { Future _load() async { final t = beginRead(#cycle); try { - final d = await CycleData.load(context.read()); - if (stillNewest(#cycle, t)) setState(() => _d = d); + final d = await (widget.loader?.call() ?? + CycleData.load(context.read())); + if (stillNewest(#cycle, t)) setState(() => (_d = d, _failed = false)); } catch (_) { - if (stillNewest(#cycle, t)) setState(() => _d = const CycleData()); + if (stillNewest(#cycle, t)) setState(() => _failed = true); } } @@ -349,6 +360,22 @@ class _CycleTabState extends State with RevisionReload { final p = P.of(c); final l = AppLocalizations.of(c); final d = _d; + if (_failed) { + return StatusCard( + l == null + ? 'Could not read your cycle data' + : l.healthCouldNotRead(l.cycleWhatCycleData), + l?.healthReadFailedBody ?? + 'The stored rows failed to load. Nothing was deleted — this is a ' + 'read that went wrong.', + fix: l?.healthTryAgain ?? 'Try again', + icon: LucideIcons.databaseZap, + onFix: () { + setState(() => _failed = false); + _load(); + }, + ); + } if (d == null) return const Center(child: CircularProgressIndicator()); if (!d.enabled) { diff --git a/test/ui2_cycle_read_failed_test.dart b/test/ui2_cycle_read_failed_test.dart new file mode 100644 index 000000000..922bb02c1 --- /dev/null +++ b/test/ui2_cycle_read_failed_test.dart @@ -0,0 +1,64 @@ +// A cycle read that throws must not look like cycle tracking being off. +// +// The catch used to swap in `const CycleData()`, which is `enabled: false`, +// so a failed read rendered the "Cycle tracking is off" opt-in card: her logs +// and prediction looked gone and the screen offered to turn on a feature that +// was already on. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/cycle_screen.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _frame(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTheme(Brightness.light), + home: Scaffold(body: ListView(children: [child])), +); + +void main() { + testWidgets('a failed read shows a retry card, not the opt-in card', ( + tester, + ) async { + var calls = 0; + await tester.pumpWidget( + _frame( + CycleTab( + loader: () async { + calls++; + if (calls == 1) throw StateError('database closed'); + return const CycleData(enabled: true); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('Turn on cycle tracking'), findsNothing); + expect(find.textContaining('Could not read your cycle data'), findsOneWidget); + + await tester.tap(find.textContaining('Could not read your cycle data')); + await tester.pumpAndSettle(); + + expect(calls, 2); + expect(find.textContaining('Could not read your cycle data'), findsNothing); + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('No period logged yet'), findsOneWidget); + }); + + testWidgets('a read that keeps failing stays on the retry card', ( + tester, + ) async { + await tester.pumpWidget( + _frame( + CycleTab( + loader: () async => throw StateError('database closed'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Cycle tracking is off'), findsNothing); + expect(find.textContaining('Could not read your cycle data'), findsOneWidget); + }); +} From 630c129a8e93ca2010366bf3d4a22f6323784b81 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:52 +0530 Subject: [PATCH 42/72] the coach menu could switch or delete the chat while a reply was still coming in new chat, opening a past chat and deleting the open chat all replace the engine's history and transcript, but an in-flight send kept appending its tool calls and answer to them and the save at the end wrote them under the newly current chat. the reply landed in the wrong conversation, the question was never saved, and the other chat's history started with an orphaned tool-call turn. those menu rows are now disabled while a reply is pending, and the handlers bail out when a send is in flight. (cherry picked from commit 75439c79f9479ad40318090243dc9156c86fa890) --- lib/ui2/screens/coach.dart | 50 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 1e9bf5e0b..b45dc28c4 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -296,12 +296,19 @@ class _CoachScreenState extends State { return ok ?? false; } + // Switching or clearing the session while a reply is in flight would hand + // the engine's live history and transcript to another chat: `send` keeps + // appending its tool calls and answer to them, and the `persist()` in + // `_send`'s finally writes them under whichever session is current then, + // so the reply lands in the wrong chat and the question is lost. void _newChat() { + if (_busy) return; _engine?.newSession(); setState(_items.clear); } Future _openSession(String id) async { + if (_busy) return; await _engine?.openSession(id); if (!mounted) return; setState(() { @@ -329,6 +336,10 @@ class _CoachScreenState extends State { final engine = _engine; final p = P.of(context); final l = AppLocalizations.of(context); + // The sheet doesn't rebuild with this screen, so the rows read this + // snapshot and stay disabled until it is reopened, even if the reply + // finishes meanwhile. The handlers re-check `_busy` regardless. + final busy = _busy; await showModalBottomSheet( context: context, backgroundColor: p.card, @@ -343,10 +354,12 @@ class _CoachScreenState extends State { _MenuRow( LucideIcons.plus, l?.coachNewChat ?? 'New chat', - onTap: () { - Navigator.of(sheet).pop(); - _newChat(); - }, + onTap: busy + ? null + : () { + Navigator.of(sheet).pop(); + _newChat(); + }, ), // NO "AI settings" ROW. Choosing the model is a setting and // settings live in Profile — [CoachSetup] is pushed from there @@ -394,16 +407,25 @@ class _CoachScreenState extends State { LucideIcons.messageSquare, s.title.isEmpty ? (l?.coachUntitledChat ?? 'Untitled chat') : s.title, sub: s.preview, - onTap: () { - Navigator.of(sheet).pop(); - _openSession(s.id); - }, - onRemove: () async { - final wasOpen = s.id == engine.sessionId; - await engine.deleteSession(s.id); - setSheet(() {}); - if (wasOpen && mounted) setState(_items.clear); - }, + onTap: busy + ? null + : () { + Navigator.of(sheet).pop(); + _openSession(s.id); + }, + // Deleting the open chat starts a new session + // inside the engine, the same hazard as above. + onRemove: busy + ? null + : () async { + if (_busy) return; + final wasOpen = s.id == engine.sessionId; + await engine.deleteSession(s.id); + setSheet(() {}); + if (wasOpen && mounted) { + setState(_items.clear); + } + }, ), ], ); From 2bff7ed8579cd00f1461e2bf9647f4594c55b81c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:58:57 +0530 Subject: [PATCH 43/72] edit profile rewrote height and weight on every save for imperial users the form pre-fills whole inches and pounds, and save converted that rounded text back to metric unconditionally, so changing only the name turned 72.5 kg into 72.57 kg and 178 cm into 177.8 cm. the same happened on the save after a health import. the form now remembers the metric values it was filled from and keeps them when the field still shows its pre-fill. (cherry picked from commit 6eb0712d6d08f80cf6e43f08b9e7bccc9a218c3b) --- lib/ui2/profile/settings.dart | 31 ++++++++-- test/profile_imperial_roundtrip_test.dart | 70 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 test/profile_imperial_roundtrip_test.dart diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index db0d71b15..598744585 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -1559,6 +1559,11 @@ class _EditProfileViewState extends State { TextEditingController(text: _u.weightField(widget.initial['weight_kg'] as num?)); late String? _sex = (widget.initial['sex'] as String?)?.toLowerCase(); + /// The metric values the height and weight fields were last filled from, + /// so [_save] can tell an untouched field from an edited one. + late num? _storedHeightCm = widget.initial['height_cm'] as num?; + late num? _storedWeightKg = widget.initial['weight_kg'] as num?; + /// When the store last gave us something. Null is "never", which is also /// what an unreadable preference reads as — the first-run word on the button /// is the safe one either way. @@ -1596,8 +1601,10 @@ class _EditProfileViewState extends State { if (!mounted) return; if (fields != null) { _age.text = _s(fields['age']); - _height.text = _u.heightField(fields['height_cm'] as num?); - _weight.text = _u.weightField(fields['weight_kg'] as num?); + _storedHeightCm = fields['height_cm'] as num?; + _storedWeightKg = fields['weight_kg'] as num?; + _height.text = _u.heightField(_storedHeightCm); + _weight.text = _u.weightField(_storedWeightKg); _sex = (fields['sex'] as String?)?.toLowerCase() ?? _sex; } setState(() { @@ -1647,10 +1654,22 @@ class _EditProfileViewState extends State { sayUnreadable(context, bad); return; } - final heightCm = - height.value == null ? null : _u.heightToCm(_height.text); - final weightKg = - weight.value == null ? null : _u.weightToKg(_weight.text); + // An untouched field keeps the stored number. Imperial pre-fills are + // rounded to whole inches and pounds, so converting them back rewrote + // 72.5 kg as 72.57 and 178 cm as 177.8 on every save and a rename alone + // moved the stored body. + final h0 = _storedHeightCm; + final w0 = _storedWeightKg; + final heightCm = height.value == null + ? null + : (h0 != null && _height.text == _u.heightField(h0)) + ? h0.toDouble() + : _u.heightToCm(_height.text); + final weightKg = weight.value == null + ? null + : (w0 != null && _weight.text == _u.weightField(w0)) + ? w0.toDouble() + : _u.weightToKg(_weight.text); final off = [ if (age.value case final a? when !plausibleAgeYears(a)) (l?.settingsAgeFieldLabel ?? 'Age'), diff --git a/test/profile_imperial_roundtrip_test.dart b/test/profile_imperial_roundtrip_test.dart new file mode 100644 index 000000000..6d6fce184 --- /dev/null +++ b/test/profile_imperial_roundtrip_test.dart @@ -0,0 +1,70 @@ +// An imperial profile form pre-fills whole inches and pounds. Saving it +// without touching those fields converted the rounded numbers back to metric, +// so 72.5 kg became 72.57 kg and 178 cm became 177.8 cm on every save — a +// rename moved the stored body. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/ui2/profile/settings.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _pump(WidgetTester t, Widget child) async { + t.view.physicalSize = const Size(1200, 3600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget( + MaterialApp(theme: buildTheme(Brightness.light), home: child)); + await t.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('untouched imperial height and weight keep the stored metric', + (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const { + 'name': 'A', + 'sex': 'f', + 'age': 34, + 'height_cm': 178.0, + 'weight_kg': 72.5, + }, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + await t.enterText(find.widgetWithText(TextField, 'A'), 'B'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!['name'], 'B'); + expect(saved!['height_cm'], 178.0); + expect(saved!['weight_kg'], 72.5); + }); + + testWidgets('an edited imperial weight is still converted', (t) async { + Map? saved; + await _pump( + t, + EditProfileView( + initial: const {'sex': 'f', 'age': 34, 'weight_kg': 72.5}, + units: UnitsController.seed(UnitSystem.imperial), + onSave: (f) async => saved = f, + )); + + // 72.5 kg pre-fills as 160 lb. + await t.enterText(find.widgetWithText(TextField, '160'), '150'); + await t.tap(find.text('Save')); + await t.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!['weight_kg'] as double, closeTo(150 * 0.45359237, 0.01)); + }); +} From 6e70272dd1d765620bc96272f2555f72bb7c5e5e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:00:11 +0530 Subject: [PATCH 44/72] a meal that failed to save or remove said nothing the log food sheet rethrew a failed save into a fire-and-forget tap and had no catch at all around remove, so a locked or full database left the sheet sitting there with no message and the meal looked logged (or gone) when it wasn't. both now show a snackbar and keep the sheet open for a retry. (cherry picked from commit 06eb3bf0728654dbbfdd67264a3ed7bd6f1104e9) --- lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 8 ++++++++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_hi.arb | 2 ++ lib/l10n/app_ru.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/ui2/screens/log_food.dart | 25 +++++++++++++++++++++--- test/nutrition_ux_559_test.dart | 34 +++++++++++++++++++++++++++++++++ 9 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c42d5748b..c766f1884 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -854,6 +854,8 @@ "nutritionExpenditureSub": "HEUTE, AUS HERZFREQUENZ UND DEINEM PROFIL", "nutritionRemoveTitle": "{label} entfernen?", "nutritionRemoveBody": "Die Mahlzeit verschwindet aus dem Tag und aus allen Durchschnitten, in die sie eingeflossen ist. Das lässt sich nicht rückgängig machen.", + "nutritionSaveFailed": "Nicht gespeichert – die Mahlzeit ist noch da. Versuch es erneut.", + "nutritionRemoveFailed": "Nicht entfernt – die Mahlzeit ist noch da. Versuch es erneut.", "nutritionNothingToMeasure": "Noch keine Daten, mit denen sich {label} vergleichen lässt", "nutritionFloorAverageBody": "An jedem vollständigen Tag fehlte bei einer Mahlzeit die Angabe zu {nutrient}, daher wäre der Durchschnitt nur eine Untergrenze.", "nutritionCountedNoFigureBody": "{count} der letzten {span} Tage wurden gezählt, aber keiner davon hatte eine Angabe zu {nutrient}.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9c75b9512..ed03b10a0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4095,6 +4095,14 @@ "@nutritionRemoveBody": { "description": "Nutrition screen: confirmation sheet body warning the removal cannot be undone" }, + "nutritionSaveFailed": "Not saved — the meal is still here. Try again.", + "@nutritionSaveFailed": { + "description": "Log food sheet: snackbar when saving a meal fails; the sheet stays open with the input intact" + }, + "nutritionRemoveFailed": "Not removed — the meal is still there. Try again.", + "@nutritionRemoveFailed": { + "description": "Log food sheet: snackbar when removing a meal fails after the user confirmed" + }, "nutritionNothingToMeasure": "Nothing to measure {label} against yet", "@nutritionNothingToMeasure": { "description": "Nutrition Goals tab: title shown when a target has no mean to compare against yet", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8f5c9e26b..2d0239ed3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -825,6 +825,8 @@ "nutritionExpenditureSub": "HOY, SEGÚN LA FRECUENCIA CARDÍACA Y TU PERFIL", "nutritionRemoveTitle": "¿Eliminar {label}?", "nutritionRemoveBody": "Elimina el día y toda media que lo contara. No se puede deshacer.", + "nutritionSaveFailed": "No se guardó — la comida sigue aquí. Inténtalo de nuevo.", + "nutritionRemoveFailed": "No se eliminó — la comida sigue ahí. Inténtalo de nuevo.", "nutritionNothingToMeasure": "Nada que medir aún frente a {label}", "nutritionFloorAverageBody": "Todos los días completos tuvieron una comida registrada sin cifra de {nutrient}, así que la media solo sería un límite inferior.", "nutritionCountedNoFigureBody": "{count} de los últimos {span} días contaron, pero ninguno llevaba una cifra de {nutrient}.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0ec3aadea..36fcb584c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -825,6 +825,8 @@ "nutritionExpenditureSub": "AUJOURD'HUI, D'APRÈS LA FRÉQUENCE CARDIAQUE ET VOTRE PROFIL", "nutritionRemoveTitle": "Supprimer {label} ?", "nutritionRemoveBody": "Cela retire ce repas du jour et de toute moyenne qui le comptait. Aucun retour en arrière possible.", + "nutritionSaveFailed": "Non enregistré — le repas est toujours là. Réessayez.", + "nutritionRemoveFailed": "Non retiré — le repas est toujours là. Réessayez.", "nutritionNothingToMeasure": "Rien à comparer pour {label} pour l'instant", "nutritionFloorAverageBody": "Chaque jour complet avait un repas enregistré sans valeur de {nutrient}, donc la moyenne ne serait qu'un minimum.", "nutritionCountedNoFigureBody": "{count} des {span} derniers jours ont compté, mais aucun ne portait de valeur de {nutrient}.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6ec970a27..fae3edad2 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -825,6 +825,8 @@ "nutritionExpenditureSub": "आज, हृदय गति और आपकी प्रोफ़ाइल के आधार पर", "nutritionRemoveTitle": "{label} हटाएँ?", "nutritionRemoveBody": "इससे यह उस दिन और उसे गिनने वाले हर औसत से हट जाएगा। यह पूर्ववत नहीं किया जा सकता।", + "nutritionSaveFailed": "सेव नहीं हुआ — भोजन अभी भी यहीं है। फिर से कोशिश करें।", + "nutritionRemoveFailed": "हटाया नहीं गया — भोजन अभी भी वहीं है। फिर से कोशिश करें।", "nutritionNothingToMeasure": "अभी {label} की तुलना करने के लिए कुछ भी नहीं है", "nutritionFloorAverageBody": "हर पूरे दिन में कम से कम एक भोजन में {nutrient} का आँकड़ा दर्ज नहीं था, इसलिए औसत केवल एक निचली सीमा होगी।", "nutritionCountedNoFigureBody": "पिछले {span} दिनों में से {count} दिन गिने गए, पर किसी में भी {nutrient} का आँकड़ा नहीं था।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 70e2f687f..f328c01cd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1769,6 +1769,8 @@ } }, "nutritionRemoveBody": "Запись исчезнет из дня и всех средних, где учитывалась. Отменить удаление нельзя.", + "nutritionSaveFailed": "Не сохранено — приём пищи остался на экране. Повторите попытку.", + "nutritionRemoveFailed": "Не удалено — приём пищи остался на месте. Повторите попытку.", "nutritionNothingToMeasure": "Пока не с чем сравнить {label}", "@nutritionNothingToMeasure": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 86bcdd66c..9914f2101 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -825,6 +825,8 @@ "nutritionExpenditureSub": "今天,根据心率和你的个人资料估算", "nutritionRemoveTitle": "删除「{label}」?", "nutritionRemoveBody": "这会把它从这一天和每个统计过它的平均值中移除,且无法撤销。", + "nutritionSaveFailed": "未保存 —— 这餐仍在此处,请重试。", + "nutritionRemoveFailed": "未移除 —— 这餐仍然保留,请重试。", "nutritionNothingToMeasure": "目前还没有可用来衡量{label}的数据", "nutritionFloorAverageBody": "每个完整的一天里都有进食记录缺少{nutrient}数值,所以平均值只能是一个下限。", "nutritionCountedNoFigureBody": "最近 {span} 天中有 {count} 天被计入,但没有一天记录了{nutrient}数值。", diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart index 7bee02560..43140238f 100644 --- a/lib/ui2/screens/log_food.dart +++ b/lib/ui2/screens/log_food.dart @@ -197,13 +197,25 @@ class _LogFoodSheetState extends State { } else { await NutritionDb.put(db, e); } - } catch (_) { + } catch (err) { + // Every caller is a fire-and-forget onTap, so a rethrow only reached + // the global error log: the sheet sat there unchanged and a locked or + // full database looked exactly like a save that worked. + debugPrint('log food: save failed: $err'); _writing = false; - rethrow; + _sayFailed((l) => l?.nutritionSaveFailed ?? + 'Not saved — the meal is still here. Try again.'); + return; } if (mounted) Navigator.of(context).pop(true); } + void _sayFailed(String Function(AppLocalizations?) text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(text(AppLocalizations.of(context))))); + } + /// Removing is destructive and there is no undo, so the entry is named back /// before it goes. Future _remove(FoodEntry e) async { @@ -216,7 +228,14 @@ class _LogFoodSheetState extends State { 'undo.', ); if (!ok) return; - await NutritionDb.delete(await LocalDb.instance, e.id); + try { + await NutritionDb.delete(await LocalDb.instance, e.id); + } catch (err) { + debugPrint('log food: remove failed: $err'); + _sayFailed((l) => l?.nutritionRemoveFailed ?? + 'Not removed — the meal is still there. Try again.'); + return; + } if (mounted) Navigator.of(context).pop(true); } diff --git a/test/nutrition_ux_559_test.dart b/test/nutrition_ux_559_test.dart index 9303c1aab..0f0ec815b 100644 --- a/test/nutrition_ux_559_test.dart +++ b/test/nutrition_ux_559_test.dart @@ -219,4 +219,38 @@ void main() { expect(find.text('45 g'), findsOneWidget); expect(find.text('75 g to go'), findsOneWidget); }); + + // A failed write used to be rethrown into a fire-and-forget onTap: it only + // reached the global error log, the sheet sat there unchanged, and a save + // that never happened looked like one that did. + testWidgets('a meal that fails to save says so and keeps the sheet open', + (t) async { + await _pump(t, const LogFoodSheet(meal: 'lunch')); + await t.runAsync(() => db.execute('DROP TABLE food_entry')); + + await t.tap(find.text('I ate lunch')); + await _settle(t); + + expect(find.text('Not saved — the meal is still here. Try again.'), + findsOneWidget); + expect(find.byType(LogFoodSheet), findsOneWidget); + }); + + testWidgets('a meal that fails to remove says so and keeps the sheet open', + (t) async { + final e = _e('f1', 'Chocolate bar', kcal: 120); + await t.runAsync(() => NutritionDb.put(db, e)); + await _pump(t, LogFoodSheet(entry: e)); + await t.runAsync(() => db.execute('DROP TABLE food_entry')); + + await t.ensureVisible(find.text('Remove this entry')); + await t.tap(find.text('Remove this entry')); + await t.pumpAndSettle(); + await t.tap(find.text('Remove')); + await _settle(t); + + expect(find.text('Not removed — the meal is still there. Try again.'), + findsOneWidget); + expect(find.byType(LogFoodSheet), findsOneWidget); + }); } From ad2fd77fcc565ca299364d4e406e9e9a8b2e79cc Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:59:06 +0530 Subject: [PATCH 45/72] changing a workout's type with a double tap popped the screen under the summary a second tap on a row while the first type write was still queued ran the pick handler again. the first one popped the picker and then the summary, and the second one's pop landed on whatever route sat underneath. the handler now ignores taps while a pick is in flight or after one landed, and re-arms only if the write fails. (cherry picked from commit 62340eefaad3f043f30bf26fa8dd70e574ce293e) --- lib/ui2/activity/summary.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index ce281ce73..de78f99dc 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -945,8 +945,16 @@ class _ActivitySummaryState extends State { // Only true once a pick actually landed — pressing back out of the picker // must return to this screen, not fall through and pop it too. var picked = false; + // A second tap on a row while the first write is still queued behind a + // derive or drain transaction would run this closure again. The first + // pick pops the picker and then this screen; the second one's pop would + // land on the route underneath (the picker is still mounted through its + // exit transition), so only one pick may be in flight or land at all. + var busy = false; await Navigator.of(c).push(MaterialPageRoute( builder: (_) => ActivityPicker(onPick: (pc, newActivity) async { + if (busy || picked) return; + busy = true; // The stored key everywhere else uses — `startWorkout(type: // a.typeKey)` is the live path's own write. `a.name` here would still // resolve through `activityByName`'s normalized lookup, but it would @@ -958,6 +966,7 @@ class _ActivitySummaryState extends State { // picker open rather than close it over a write that never // happened — `onPick` is a `void Function`, so there is no caller // to hand this failure back to. + busy = false; return; } picked = true; From cc4f1c06c41e2ea5eca50215713bf313e731b9ec Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:00:16 +0530 Subject: [PATCH 46/72] the step goal editor closed as if it had saved when the goal could not be read typing "12k" or "12,000" as a step goal closed the editor and kept the old goal with no message, the same as leaving the field empty. an unreadable goal now keeps the editor open with what was typed and says it was not saved, like the other number forms do. a blank field still just closes it. (cherry picked from commit 1b46882d90d4aef45c1230b0f2b259cb23820887) --- lib/ui2/screens/metric_detail.dart | 9 ++- test/ui2_step_goal_unreadable_test.dart | 74 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 test/ui2_step_goal_unreadable_test.dart diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 5ee95d824..4717d6ce0 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -1838,7 +1838,14 @@ class _StepGoalGaugeState extends State<_StepGoalGauge> { Future _save() async { final t = Typed.of(_ctrl.text); - final typed = (t.bad || t.value == null) ? null : t.value!.round(); + // "12k" or "12,000" is not a blank field: closing the editor here kept + // the old goal under a screen that looked as if it had saved the new one. + // Keep the editor open with what was typed and say it was not read. + if (t.bad) { + sayUnreadable(context, [uiText(context, 'Goal')]); + return; + } + final typed = t.value?.round(); if (typed == null) { setState(() => _editing = false); return; diff --git a/test/ui2_step_goal_unreadable_test.dart b/test/ui2_step_goal_unreadable_test.dart new file mode 100644 index 000000000..79f2e8973 --- /dev/null +++ b/test/ui2_step_goal_unreadable_test.dart @@ -0,0 +1,74 @@ +// The step goal editor on the steps detail screen must not close as if it had +// saved when what was typed cannot be read as a number. +// +// Driven through the real `MetricDetail('steps', data: d)` widget, which skips +// the repo/AppState load path. There is no AppState Provider above it, so any +// path that reaches `updateProfile` would throw here too. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future _pumpSteps(WidgetTester t) async { + t.view.physicalSize = const Size(390 * 3, 900 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final today = DateTime.now(); + final series = [ + for (var i = 6; i >= 0; i--) + ( + t: today.subtract(Duration(days: i)).millisecondsSinceEpoch ~/ 1000, + v: 6000.0, + ), + ]; + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: MetricDetail( + 'steps', + data: MetricData(daysAvailable: 10, series: series, stepGoal: 10000), + ), + ), + )); + await t.pumpAndSettle(); +} + +Future _openEditorAndType(WidgetTester t, String text) async { + final edit = find.bySemanticsLabel('Edit daily step goal'); + await t.ensureVisible(edit); + await t.tap(edit); + await t.pumpAndSettle(); + await t.enterText(find.byType(TextField), text); + await t.pump(); + await t.tap(find.bySemanticsLabel('Save step goal')); + await t.pumpAndSettle(); +} + +void main() { + for (final typed in ['12k', '12,000', '12.000 steps']) { + testWidgets('an unreadable goal "$typed" keeps the editor open and says so', + (t) async { + final handle = t.ensureSemantics(); + await _pumpSteps(t); + await _openEditorAndType(t, typed); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('Nothing was saved'), findsOneWidget); + // The editor is still up, holding exactly what was typed. + expect(find.byType(TextField), findsOneWidget); + expect(find.text(typed), findsOneWidget); + handle.dispose(); + }); + } + + testWidgets('a blank goal still closes the editor quietly', (t) async { + final handle = t.ensureSemantics(); + await _pumpSteps(t); + await _openEditorAndType(t, ''); + + expect(find.byType(SnackBar), findsNothing); + expect(find.byType(TextField), findsNothing); + handle.dispose(); + }); +} From 20f617a19fd3f505795edac9e035fbf14f56045c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:59:43 +0530 Subject: [PATCH 47/72] a second X tap or back swipe while breathing was closing popped one screen too many stopping a session with the quiet windows on takes a moment (banking the session, scoring both windows, re-reading the effect) and nothing on screen changes, so a second tap was natural. neither the X nor the back handler guarded against running twice, and each ended in its own pop(), so the second one removed the route underneath: wellness, or the home route behind the /breathing deep link, leaving a blank screen. both now go through one close that runs once, and the route stays un-poppable until that close has popped it. (cherry picked from commit 1a34f9eabb92dac1580638d5dfab366beeb56555) --- lib/ui2/screens/calm_breathing.dart | 44 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/ui2/screens/calm_breathing.dart b/lib/ui2/screens/calm_breathing.dart index 3b6186340..a298f21e7 100644 --- a/lib/ui2/screens/calm_breathing.dart +++ b/lib/ui2/screens/calm_breathing.dart @@ -253,6 +253,25 @@ class _CalmBreathingState extends State bool _paceRead = false; + /// Set by the first X tap or back swipe. The teardown behind it (banking the + /// session, scoring both quiet windows, re-reading the effect) takes long + /// enough with nothing changing on screen that a second tap is natural, and + /// each exit ends in its own `pop()` — the second one removed the route + /// underneath (Wellness, or the home route behind a /breathing deep link). + bool _closing = false; + + /// The one exit for both the X and the back gesture. + Future _close() async { + if (_closing) return; + setState(() => _closing = true); + if (_running) { + await _stop(abort: true); + } else if (_quiet != null) { + await _abortQuiet(); + } + if (mounted) Navigator.of(context).pop(); + } + @override void initState() { super.initState(); @@ -519,15 +538,13 @@ class _CalmBreathingState extends State final l = AppLocalizations.of(c); final busy = _running || _quiet != null; return PopScope( - canPop: !busy, - onPopInvokedWithResult: (didPop, _) async { - if (didPop || !busy) return; - if (_running) { - await _stop(abort: true); - } else { - await _abortQuiet(); - } - if (mounted) Navigator.of(context).pop(); + // Held closed for the whole teardown, not just while busy: `_stop` clears + // `_running` before its last `_loadEffect`, and a swipe in that gap would + // pop natively ahead of the pending `pop()` in `_close`. + canPop: !busy && !_closing, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _close(); }, child: Scaffold( backgroundColor: p.bg, @@ -542,14 +559,7 @@ class _CalmBreathingState extends State child: Pressable( semanticLabel: l?.calmBreathingCloseBreathing ?? 'Close breathing', - onTap: () async { - if (_running) { - await _stop(abort: true); - } else if (_quiet != null) { - await _abortQuiet(); - } - if (c.mounted) Navigator.of(c).pop(); - }, + onTap: _close, child: Icon(LucideIcons.x, size: 22, color: p.ink2), ), ), From 08aec6398be5dae232bc8b944338ca5ee83a21ee Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:08:32 +0530 Subject: [PATCH 48/72] a throw while breathing was closing left the screen stuck the close guard is never cleared and holds the back gesture shut, so if banking the session or re-reading the effect threw, the X went dead and there was no way off the screen. the pop now runs in a finally, so the one exit always happens and the error still surfaces. (cherry picked from commit f3fdb5ad754b54155245368039d8edda795c7e2d) --- lib/ui2/screens/calm_breathing.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/ui2/screens/calm_breathing.dart b/lib/ui2/screens/calm_breathing.dart index a298f21e7..925fecd17 100644 --- a/lib/ui2/screens/calm_breathing.dart +++ b/lib/ui2/screens/calm_breathing.dart @@ -264,12 +264,19 @@ class _CalmBreathingState extends State Future _close() async { if (_closing) return; setState(() => _closing = true); - if (_running) { - await _stop(abort: true); - } else if (_quiet != null) { - await _abortQuiet(); + // The pop sits in `finally`: `_closing` is never cleared and `canPop` is + // false while it is set, so a throw from the teardown (the session insert, + // or the effect re-read) would otherwise leave the user on a screen with a + // dead X and a blocked back gesture. The error still propagates. + try { + if (_running) { + await _stop(abort: true); + } else if (_quiet != null) { + await _abortQuiet(); + } + } finally { + if (mounted) Navigator.of(context).pop(); } - if (mounted) Navigator.of(context).pop(); } @override From 00ef8b5ac3c1b1318c45eacaca93ae4ad9b635c6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 14:59:56 +0530 Subject: [PATCH 49/72] the day strain curve was an hour off on dst days i slotted each curve point by minutes elapsed since local midnight on a fixed 1440-minute grid. a fall-back day is 1500 minutes, so everything after the repeated hour drew an hour late and 23:00-24:00 fell off the grid, which left the cumulative trace short of the day strain printed under it. a spring-forward day drew everything after the gap an hour early. points now go to their local clock minute, with the later sample winning on the repeated hour. (cherry picked from commit 8a82d7b77e5c7b5c95cfd85b17ee0131faaa3e1f) --- lib/ui2/activity/day_strain.dart | 40 ++++++++----- test/day_strain_dst_grid_test.dart | 90 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 test/day_strain_dst_grid_test.dart diff --git a/lib/ui2/activity/day_strain.dart b/lib/ui2/activity/day_strain.dart index 0fdadc85f..914938f53 100644 --- a/lib/ui2/activity/day_strain.dart +++ b/lib/ui2/activity/day_strain.dart @@ -94,6 +94,31 @@ class DayStrainData { bool get hasCurve => curve.any((v) => v != null); + /// Lays the curve's epoch-second points onto the fixed 00:00–24:00 axis, + /// one slot per WALL-CLOCK minute of the first point's local day. + /// + /// The slot is the local `hour * 60 + minute`, not minutes elapsed since + /// midnight: a fall-back day is 1500 minutes long and a spring-forward day + /// 1380, so elapsed minutes pushed everything after the transition an hour + /// off the axis labels, and on fall-back cut 23:00–24:00 — the end of a + /// cumulative curve, which left the trace short of the day strain printed + /// under it. On the repeated hour the later sample wins (points arrive in + /// time order), which is the right one for a running total. + static (DateTime?, List) gridCurve(List<(int, double)> pts) { + if (pts.isEmpty) return (null, const []); + final first = DateTime.fromMillisecondsSinceEpoch(pts.first.$1 * 1000); + final day = DateTime(first.year, first.month, first.day); + final out = List.filled(1440, null); + for (final p in pts) { + final t = DateTime.fromMillisecondsSinceEpoch(p.$1 * 1000); + if (t.year != day.year || t.month != day.month || t.day != day.day) { + continue; + } + out[t.hour * 60 + t.minute] = p.$2; + } + return (day, out); + } + /// [forDay] is the label the caller is looking at; null means today. Home's /// day switcher passes it, so a past day's strain ring opens that day's /// curve rather than today's. @@ -109,20 +134,7 @@ class DayStrainData { ((e['t'] as num).toInt(), (e['v'] as num).toDouble()), ]; - DateTime? day; - var grid = const []; - if (pts.isNotEmpty) { - final first = - DateTime.fromMillisecondsSinceEpoch(pts.first.$1 * 1000); - day = DateTime(first.year, first.month, first.day); - final dayStart = day.millisecondsSinceEpoch ~/ 1000; - final out = List.filled(1440, null); - for (final p in pts) { - final i = (p.$1 - dayStart) ~/ 60; - if (i >= 0 && i < 1440) out[i] = p.$2; - } - grid = out; - } + final (day, grid) = gridCurve(pts); // Wear for THE DAY THE CURVE CAME FROM, not the day we asked for — both // reads fall back the same way, so asking for the resolved label is what diff --git a/test/day_strain_dst_grid_test.dart b/test/day_strain_dst_grid_test.dart new file mode 100644 index 000000000..f05269c00 --- /dev/null +++ b/test/day_strain_dst_grid_test.dart @@ -0,0 +1,90 @@ +// The day-strain curve sits under a fixed 00:00–24:00 axis. It used to slot +// each point by minutes ELAPSED since local midnight, so on a fall-back day +// (1500 minutes) everything after the repeated hour landed an hour late and +// 23:00–24:00 fell off the end of the grid, and on a spring-forward day +// (1380 minutes) everything after the gap landed an hour early. +// +// The DST cases only bite when the test machine's local zone observes DST; +// they search the year for such days and skip when there are none. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; + +DateTime? _firstDayOfLength(int seconds) { + for (var d = DateTime(2026, 1, 1); + d.year == 2026; + d = DateTime(d.year, d.month, d.day + 1)) { + if (localDayLengthSec(dayLabelOf(d)) == seconds) return d; + } + return null; +} + +/// One point per minute of [day], value = the minute's sequence number, so a +/// cumulative curve whose last point is the largest. +List<(int, double)> _everyMinute(DateTime day) { + final start = day.millisecondsSinceEpoch ~/ 1000; + final len = localDayLengthSec(dayLabelOf(day))!; + return [ + for (var s = 0; s < len; s += 60) (start + s, (s ~/ 60).toDouble()), + ]; +} + +void main() { + test('an ordinary day maps every point to its clock minute', () { + final day = DateTime(2026, 6, 10); + final (got, grid) = DayStrainData.gridCurve(_everyMinute(day)); + expect(got, day); + expect(grid.length, 1440); + expect(grid.every((v) => v != null), isTrue); + expect(grid[23 * 60 + 30], 1410); + }); + + test('fall-back day keeps the last hour and draws it at its clock time', () { + final day = _firstDayOfLength(90000); + if (day == null) { + markTestSkipped('local zone has no fall-back day'); + return; + } + final pts = _everyMinute(day); + final (_, grid) = DayStrainData.gridCurve(pts); + expect(grid.length, 1440); + // The final point is the end of the cumulative curve — the headline. + expect(grid[1439], pts.last.$2); + // A late-evening sample lands under its own clock minute. + final t = DateTime(day.year, day.month, day.day, 23, 10); + final want = pts + .firstWhere((p) => p.$1 == t.millisecondsSinceEpoch ~/ 1000) + .$2; + expect(grid[23 * 60 + 10], want); + expect(grid.every((v) => v != null), isTrue); + }); + + test('spring-forward day draws points after the gap at their clock time', + () { + final day = _firstDayOfLength(82800); + if (day == null) { + markTestSkipped('local zone has no spring-forward day'); + return; + } + final pts = _everyMinute(day); + final (_, grid) = DayStrainData.gridCurve(pts); + expect(grid[1439], pts.last.$2); + final t = DateTime(day.year, day.month, day.day, 20, 0); + final want = pts + .firstWhere((p) => p.$1 == t.millisecondsSinceEpoch ~/ 1000) + .$2; + expect(grid[20 * 60], want); + // Exactly the skipped hour stays empty. + expect(grid.where((v) => v == null).length, 60); + }); + + test('points from another local day are ignored', () { + final day = DateTime(2026, 6, 10); + final next = DateTime(2026, 6, 11, 0, 5).millisecondsSinceEpoch ~/ 1000; + final (_, grid) = + DayStrainData.gridCurve([..._everyMinute(day), (next, 9999)]); + expect(grid.contains(9999), isFalse); + }); +} From f179031ec5347e5f45519f2e41c24ccfa71c28e1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:01:04 +0530 Subject: [PATCH 50/72] sub-tab pills never told a screen reader which one was selected the active pill in SubTabs only differed by fill and font weight, so voiceover and talkback read every range and section pill as the same plain button. each pill now carries the selected state in a mutually exclusive group, the same way the bottom nav does. (cherry picked from commit 81d51f82ba32becfb5581d741f7f78325dd9e6d2) --- lib/ui2/grammar.dart | 10 ++- test/ui2_subtabs_selected_semantics_test.dart | 63 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 test/ui2_subtabs_selected_semantics_test.dart diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 949d56d2c..852ffddd7 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -1997,7 +1997,14 @@ class _SubTabsState extends State { builder: (_) { final off = disabled.contains(i); final on = i == index && !off; - return Pressable( + // The active pill differs from the rest only by fill and + // weight, so without the flag a screen reader read every + // pill as the same plain button and there was no way to + // hear which range or section was showing. + return Semantics( + selected: on, + inMutuallyExclusiveGroup: true, + child: Pressable( key: _keys.putIfAbsent(i, () => GlobalKey()), onTap: off ? null : () => widget.onTap(i), // `Pressable` drops `Semantics(button: true)` when `onTap` is @@ -2038,6 +2045,7 @@ class _SubTabsState extends State { ), ), ), + ), ); }, ), diff --git a/test/ui2_subtabs_selected_semantics_test.dart b/test/ui2_subtabs_selected_semantics_test.dart new file mode 100644 index 000000000..284b073c4 --- /dev/null +++ b/test/ui2_subtabs_selected_semantics_test.dart @@ -0,0 +1,63 @@ +// SubTabs drives the 7/30/90-day range on every metric detail and the +// sections of the Health, Wellness, Workouts and Devices screens. The active +// pill is told apart only by fill and font weight, so a screen reader has to +// be given the selected state explicitly or it reads every pill the same. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Widget _harness(Widget child) => MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold(body: Center(child: child)), + ); + +void main() { + const items = ['7 days', '30 days', '90 days']; + + testWidgets('only the active pill is announced as selected', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget(_harness(SubTabs(items, 1, (_) {}))); + await t.pumpAndSettle(); + + expect( + t.getSemantics(find.text('30 days')), + containsSemantics(isSelected: true, isButton: true), + ); + for (final other in const ['7 days', '90 days']) { + expect( + t.getSemantics(find.text(other)), + containsSemantics(isSelected: false, isButton: true), + ); + } + handle.dispose(); + }); + + testWidgets('selection follows the index', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget(_harness(SubTabs(items, 0, (_) {}))); + await t.pumpAndSettle(); + expect(t.getSemantics(find.text('7 days')), + containsSemantics(isSelected: true)); + + await t.pumpWidget(_harness(SubTabs(items, 2, (_) {}))); + await t.pumpAndSettle(); + expect(t.getSemantics(find.text('7 days')), + containsSemantics(isSelected: false)); + expect(t.getSemantics(find.text('90 days')), + containsSemantics(isSelected: true)); + handle.dispose(); + }); + + testWidgets('a disabled pill is never announced as selected', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _harness(SubTabs(items, 1, (_) {}, disabled: const {1}))); + await t.pumpAndSettle(); + expect( + t.getSemantics(find.text('30 days')), + containsSemantics(isSelected: false), + ); + handle.dispose(); + }); +} From c3d695efb46874f30a5e346f835ce61268c87aec Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:00:58 +0530 Subject: [PATCH 51/72] sleep scrub card showed skin temp as a bare 'Temp -12.40' the value is a raw sensor count minus the day's median, not degrees, but it sat next to '52 bpm' with no marker and padded to two decimals it never had. it's now labelled as relative and printed signed at one decimal. (cherry picked from commit 6c7a16df515f1871e8473eff13988a745120bad4) --- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/ui2/screens/sleep_detail.dart | 15 ++++++++++++++- test/ui2_sleep_detail_test.dart | 12 ++++++++++++ 9 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c766f1884..e2cbb3f84 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1733,6 +1733,7 @@ "sleepDetailHrv": "HRV", "sleepDetailBreathing": "Atmung", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "Nicht gemessen", "sleepDetailNoSignalAtMoment": "Zu diesem Zeitpunkt wurde kein Signal aufgezeichnet.", "sleepDetailStageAwake": "Wach", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ed03b10a0..b5aef66cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8652,6 +8652,10 @@ "@sleepDetailTemp": { "description": "Short label for skin temperature in the scrub readout card." }, + "sleepDetailTempRelative": "Temp (rel.)", + "@sleepDetailTempRelative": { + "description": "Short label for the scrub readout card's skin-temperature value, which is a relative sensor deviation from that day's median, not degrees. Keep the 'relative' meaning." + }, "sleepDetailNotMeasuredCap": "Not measured", "@sleepDetailNotMeasuredCap": { "description": "Capitalized, standalone label shown in place of a stage pill when the instant was not recorded." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2d0239ed3..3215e1623 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1693,6 +1693,7 @@ "sleepDetailHrv": "VFC", "sleepDetailBreathing": "Respiración", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "No medido", "sleepDetailNoSignalAtMoment": "No se registró ninguna señal en este momento.", "sleepDetailStageAwake": "Despierto", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 36fcb584c..353eb8844 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1683,6 +1683,7 @@ "sleepDetailHrv": "VFC", "sleepDetailBreathing": "Respiration", "sleepDetailTemp": "Temp.", + "sleepDetailTempRelative": "Temp. (rel.)", "sleepDetailNotMeasuredCap": "Non mesuré", "sleepDetailNoSignalAtMoment": "Aucun signal enregistré à cet instant.", "sleepDetailStageAwake": "Éveil", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index fae3edad2..3343e9ca9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1683,6 +1683,7 @@ "sleepDetailHrv": "HRV", "sleepDetailBreathing": "श्वसन", "sleepDetailTemp": "तापमान", + "sleepDetailTempRelative": "तापमान (सापेक्ष)", "sleepDetailNotMeasuredCap": "मापा नहीं गया", "sleepDetailNoSignalAtMoment": "इस पल कोई संकेत रिकॉर्ड नहीं हुआ।", "sleepDetailStageAwake": "जागृत", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f328c01cd..70380f86b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3993,6 +3993,7 @@ "sleepDetailHrv": "ВСР", "sleepDetailBreathing": "Дыхание", "sleepDetailTemp": "Температура", + "sleepDetailTempRelative": "Темп. (отн.)", "sleepDetailNotMeasuredCap": "Не измерено", "sleepDetailNoSignalAtMoment": "В этот момент сигнал не записан.", "sleepDetailStageAwake": "Бодрствование", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9914f2101..1177b9a90 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1683,6 +1683,7 @@ "sleepDetailHrv": "心率变异性", "sleepDetailBreathing": "呼吸", "sleepDetailTemp": "体温", + "sleepDetailTempRelative": "体温(相对)", "sleepDetailNotMeasuredCap": "未测量", "sleepDetailNoSignalAtMoment": "此刻没有记录到信号。", "sleepDetailStageAwake": "清醒", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 5371b8f5c..6f5a4c25b 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -407,6 +407,18 @@ String? sleepDayAfterOverride( String? current, String edited, String? latestDay) => current == edited ? sleepStepperDay(edited, latestDay) : current; +/// The scrub card's skin-temperature reading. The timeline value is the +/// sensor's raw count minus the day's median, already rounded to one decimal: +/// there is no °C behind it. Printed bare as "-12.40" beside "52 bpm" it read +/// as a twelve-degree drop, so it carries an explicit sign and no padding +/// past the precision it actually has. +@visibleForTesting +String sleepScrubTempDelta(num v) { + final s = v.abs().toStringAsFixed(1); + if (s == '0.0') return '±0.0'; + return v < 0 ? '−$s' : '+$s'; +} + class SleepDetail extends StatefulWidget { final SleepData? data; @@ -1498,7 +1510,8 @@ class _SleepDetailState extends State with RevisionReload { (l?.sleepDetailBreathing ?? 'Breathing', '${at('resp')!.toStringAsFixed(1)} br/min', C.teal), if (at('skin_temp') != null) - (l?.sleepDetailTemp ?? 'Temp', at('skin_temp')!.toStringAsFixed(2), C.orange), + (l?.sleepDetailTempRelative ?? 'Temp (rel.)', + sleepScrubTempDelta(at('skin_temp')!), C.orange), ]; return Padding( diff --git a/test/ui2_sleep_detail_test.dart b/test/ui2_sleep_detail_test.dart index 12342834b..ea5c96c86 100644 --- a/test/ui2_sleep_detail_test.dart +++ b/test/ui2_sleep_detail_test.dart @@ -470,6 +470,18 @@ void main() { }); }); + group('sleepScrubTempDelta', () { + test('a relative sensor delta is signed and keeps its one decimal', () { + expect(sleepScrubTempDelta(-12.4), '−12.4'); + expect(sleepScrubTempDelta(3.1), '+3.1'); + }); + + test('zero carries no sign it does not have', () { + expect(sleepScrubTempDelta(0), '±0.0'); + expect(sleepScrubTempDelta(-0.04), '±0.0'); + }); + }); + group('sleepStepperDay', () { test('stepping onto the newest night follows it rather than pinning it', () { From 595ac50e22d415a4bc6ec6b8c430dbf69c7de4bb Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:01:16 +0530 Subject: [PATCH 52/72] the journal's talk it through with ai link was english in every locale the link above the journal note field was a hard-coded english string, and the title of the sheet it opens had no translation outside english and russian. both now come from the arb files in every locale. (cherry picked from commit 2f94ca1ba6f46f8355020388ca959d86e1566538) --- lib/l10n/app_de.arb | 2 ++ lib/l10n/app_en.arb | 4 +++ lib/l10n/app_es.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_hi.arb | 2 ++ lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 2 ++ lib/ui2/screens/journal_compose.dart | 5 ++-- test/journal_ai_entry_l10n_test.dart | 43 ++++++++++++++++++++++++++++ 9 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test/journal_ai_entry_l10n_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e2cbb3f84..cee3a5a9e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -773,6 +773,8 @@ "journalComposeTodaySection": "Heute", "journalComposeTrackSomethingElse": "Etwas anderes erfassen", "journalComposeAnythingElseLabel": "Sonstiges", + "journalComposeTalkItThroughWithAi": "Mit KI durchsprechen", + "supplementTalkItThrough88a9d2": "Durchsprechen", "journalComposeAnythingElseHint": "Ein paar Worte zum Tag.", "journalComposeSavingLabel": "Wird gespeichert", "journalComposeHowAreYouFeeling": "Wie fühlst du dich?", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5aef66cb..f2cdfad98 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3700,6 +3700,10 @@ "@journalComposeAnythingElseLabel": { "description": "Label for the free-text journal note field." }, + "journalComposeTalkItThroughWithAi": "Talk it through with AI", + "@journalComposeTalkItThroughWithAi": { + "description": "Link above the journal note field that opens an AI chat about the day. Shown only when an AI key is configured." + }, "journalComposeAnythingElseHint": "A line about the day.", "@journalComposeAnythingElseHint": { "description": "Placeholder hint for the free-text journal note field." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3215e1623..72a83d045 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -744,6 +744,8 @@ "journalComposeTodaySection": "Hoy", "journalComposeTrackSomethingElse": "Registrar algo más", "journalComposeAnythingElseLabel": "Algo más", + "journalComposeTalkItThroughWithAi": "Hablarlo con la IA", + "supplementTalkItThrough88a9d2": "Hablarlo", "journalComposeAnythingElseHint": "Una línea sobre el día.", "journalComposeSavingLabel": "Guardando", "journalComposeHowAreYouFeeling": "¿Cómo te sientes?", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 353eb8844..06997c709 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -744,6 +744,8 @@ "journalComposeTodaySection": "Aujourd'hui", "journalComposeTrackSomethingElse": "Suivre autre chose", "journalComposeAnythingElseLabel": "Autre chose", + "journalComposeTalkItThroughWithAi": "En parler avec l'IA", + "supplementTalkItThrough88a9d2": "En parler", "journalComposeAnythingElseHint": "Une ligne sur la journée.", "journalComposeSavingLabel": "Enregistrement", "journalComposeHowAreYouFeeling": "Comment vous sentez-vous ?", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 3343e9ca9..324870bab 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -744,6 +744,8 @@ "journalComposeTodaySection": "आज", "journalComposeTrackSomethingElse": "कुछ और ट्रैक करें", "journalComposeAnythingElseLabel": "कुछ और", + "journalComposeTalkItThroughWithAi": "AI के साथ बात करें", + "supplementTalkItThrough88a9d2": "बात करें", "journalComposeAnythingElseHint": "इस दिन के बारे में एक पंक्ति लिखें।", "journalComposeSavingLabel": "सेव हो रहा है", "journalComposeHowAreYouFeeling": "आप कैसा महसूस कर रहे हैं?", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 70380f86b..4cef25d17 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1591,6 +1591,7 @@ "journalComposeTodaySection": "Сегодня", "journalComposeTrackSomethingElse": "Добавить свой показатель", "journalComposeAnythingElseLabel": "Что-нибудь ещё", + "journalComposeTalkItThroughWithAi": "Обсудить с ИИ", "journalComposeAnythingElseHint": "Несколько слов о дне.", "journalComposeSavingLabel": "Сохранение", "journalComposeHowAreYouFeeling": "Как вы себя чувствуете?", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1177b9a90..7e27582d6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -744,6 +744,8 @@ "journalComposeTodaySection": "今天", "journalComposeTrackSomethingElse": "记录其他内容", "journalComposeAnythingElseLabel": "其他备注", + "journalComposeTalkItThroughWithAi": "和 AI 聊聊", + "supplementTalkItThrough88a9d2": "聊一聊", "journalComposeAnythingElseHint": "写一句关于这一天的话。", "journalComposeSavingLabel": "保存中", "journalComposeHowAreYouFeeling": "你感觉怎么样?", diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index d5dde3e21..0b9ddc44e 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -424,9 +424,8 @@ class _JournalComposeState extends State { size: 16, color: p.on(C.domMind)), const SizedBox(width: S.x1), Text( - // Not localized yet — a new entry point, - // not a copy pass over the whole screen. - 'Talk it through with AI', + l?.journalComposeTalkItThroughWithAi ?? + 'Talk it through with AI', style: F.over .copyWith(color: p.on(C.domMind)), ), diff --git a/test/journal_ai_entry_l10n_test.dart b/test/journal_ai_entry_l10n_test.dart new file mode 100644 index 000000000..64fb2881c --- /dev/null +++ b/test/journal_ai_entry_l10n_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The journal's "Talk it through with AI" link was a bare English literal, +/// so it stayed English above otherwise translated fields, and the sheet it +/// opens fell back to the English template title in most locales. +void main() { + const locales = ['de', 'es', 'fr', 'hi', 'ru', 'zh']; + const keys = [ + 'journalComposeTalkItThroughWithAi', + 'supplementTalkItThrough88a9d2', + ]; + + Map arb(String locale) => + jsonDecode(File('lib/l10n/app_$locale.arb').readAsStringSync()) + as Map; + + test('english template defines the keys', () { + final en = arb('en'); + for (final k in keys) { + expect(en[k], isA(), reason: k); + } + }); + + for (final locale in locales) { + test('$locale translates the journal AI entry and sheet title', () { + final en = arb('en'); + final l = arb(locale); + for (final k in keys) { + expect(l[k], isA(), reason: '$locale $k'); + expect(l[k], isNot(en[k]), reason: '$locale $k'); + } + }); + } + + test('the compose screen reads the link label from localizations', () { + final src = + File('lib/ui2/screens/journal_compose.dart').readAsStringSync(); + expect(src, contains('l?.journalComposeTalkItThroughWithAi')); + }); +} From 6194331859facc5212fa0a13ec11218a65a35843 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 15:01:32 +0530 Subject: [PATCH 53/72] sleep screen printed bpm, ms and br/min untranslated with '.' decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the scrub card, the overnight summary and the sleeping-hr-high card built their readings from raw literals and toStringAsFixed, so a russian user saw '52 bpm' and '14.2 br/min' here while metric detail showed the same night as 'уд/мин' and '14,2'. they now go through displayFixed and the translated unit. (cherry picked from commit 852414ab92b3e29ea44098ba3cb70f43af73b672) --- lib/ui2/screens/sleep_detail.dart | 30 +++++++++++----- test/sleep_detail_units_locale_test.dart | 45 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 test/sleep_detail_units_locale_test.dart diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 6f5a4c25b..333d96265 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -24,6 +24,7 @@ import '../../compute/vendor_sleep.dart' show vendorStagesReported; import '../../data/day_label.dart'; import '../../data/local_repository.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/decimal_text.dart'; import '../../state/app_state.dart'; import '../../state/locale_controller.dart'; import '../../state/prefs.dart'; @@ -147,6 +148,15 @@ String _pct(double v) => '${v.round()}%'; String sleepEfficiencyDelta(AppLocalizations? l, double v) => l?.sleepDetailPointsDelta('${v.round()}') ?? '${v.round()} points'; +/// A reading with its unit, in the display locale: metric detail prints the +/// same number through [displayFixed] and a translated unit, so a raw +/// "14.2 br/min" here showed a Russian user two different spellings of one +/// night. +@visibleForTesting +String sleepReadingText(AppLocalizations? l, num value, int places, + String unit) => + '${displayFixed(value, places)} ${localizedText(l, unit)}'; + /// SLP-13 — the three staged figures of a night, as the intervals they are. /// /// Null when the night published no stage split. A night with no REM figure @@ -1503,12 +1513,14 @@ class _SleepDetailState extends State with RevisionReload { : stages[(_scrub! * (stages.length - 1)).round()]; final items = <(String, String, Color)>[ if (at('hr') != null) - (l?.sleepDetailHeartRate ?? 'Heart rate', '${at('hr')!.round()} bpm', C.red), + (l?.sleepDetailHeartRate ?? 'Heart rate', + sleepReadingText(l, at('hr')!.round(), 0, 'bpm'), C.red), if (at('hrv') != null) - (l?.sleepDetailHrv ?? 'HRV', '${at('hrv')!.round()} ms', C.green), + (l?.sleepDetailHrv ?? 'HRV', + sleepReadingText(l, at('hrv')!.round(), 0, 'ms'), C.green), if (at('resp') != null) (l?.sleepDetailBreathing ?? 'Breathing', - '${at('resp')!.toStringAsFixed(1)} br/min', C.teal), + sleepReadingText(l, at('resp')!, 1, 'br/min'), C.teal), if (at('skin_temp') != null) (l?.sleepDetailTempRelative ?? 'Temp (rel.)', sleepScrubTempDelta(at('skin_temp')!), C.orange), @@ -2018,8 +2030,8 @@ class _SleepDetailState extends State with RevisionReload { vsBase != null) { items.add(InsightCard( l?.sleepDetailSleepingHrHighTitle ?? 'Sleeping heart rate ran high', - l?.sleepDetailSleepingHrHighBody(vsBase.toStringAsFixed(1)) ?? - '${vsBase.toStringAsFixed(1)} bpm above your usual. Common after alcohol, a late meal, a hard session or an infection starting. A measurement, not a diagnosis.', + l?.sleepDetailSleepingHrHighBody(displayFixed(vsBase, 1)) ?? + '${displayFixed(vsBase, 1)} bpm above your usual. Common after alcohol, a late meal, a hard session or an infection starting. A measurement, not a diagnosis.', icon: LucideIcons.heartPulse, color: C.red, )); @@ -2088,13 +2100,15 @@ class _SleepDetailState extends State with RevisionReload { final summary = <(String, String, Color)>[ if (noc is Map && noc['sleeping_hr_avg'] != null) (loc?.sleepDetailSleepingHr ?? 'SLEEPING HR', - '${noc['sleeping_hr_avg']} bpm', C.red), + sleepReadingText(loc, noc['sleeping_hr_avg'] as num, 0, 'bpm'), + C.red), if (noc is Map && noc['sleeping_hr_min'] != null) - (loc?.sleepDetailLowest ?? 'LOWEST', '${noc['sleeping_hr_min']} bpm', + (loc?.sleepDetailLowest ?? 'LOWEST', + sleepReadingText(loc, noc['sleeping_hr_min'] as num, 0, 'bpm'), C.blue), if (respV != null) (loc?.sleepDetailBreathingCaps ?? 'BREATHING', - '${respV.toStringAsFixed(1)} br/min', C.teal), + sleepReadingText(loc, respV, 1, 'br/min'), C.teal), ]; if (all.isEmpty) { diff --git a/test/sleep_detail_units_locale_test.dart b/test/sleep_detail_units_locale_test.dart new file mode 100644 index 000000000..aacea89b5 --- /dev/null +++ b/test/sleep_detail_units_locale_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/locale_controller.dart'; +import 'package:openstrap_edge/ui2/screens/sleep_detail.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Metric detail prints these readings with displayFixed and a translated + // unit; the sleep screen has to print the same night the same way. + test('sleep readings use the display locale unit and separator', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('ru')..useForPresentation(); + addTearDown(controller.dispose); + final ru = lookupAppLocalizations(const Locale('ru')); + + expect(sleepReadingText(ru, 14.2, 1, 'br/min'), + '14,2 ${ru.supplementBrMin0041f9}'); + expect(sleepReadingText(ru, 52, 0, 'bpm'), '52 ${ru.activityLiveBpmUnit}'); + expect(sleepReadingText(ru, 48, 0, 'ms'), '48 ${ru.cycleUnitMs}'); + }); + + test('english readings are unchanged', () { + SharedPreferences.setMockInitialValues({}); + final controller = LocaleController.seed('en')..useForPresentation(); + addTearDown(controller.dispose); + final en = lookupAppLocalizations(const Locale('en')); + + expect(sleepReadingText(en, 14.2, 1, 'br/min'), '14.2 br/min'); + expect(sleepReadingText(en, 52, 0, 'bpm'), '52 bpm'); + }); + + // The readings sit inside a screen that needs a full app to pump; the + // regression is a raw literal slipping back in. + test('the sleep screen formats no reading with toStringAsFixed', () { + final src = File('lib/ui2/screens/sleep_detail.dart').readAsStringSync(); + expect(src.contains('toStringAsFixed'), isFalse); + expect(RegExp(r"\} (bpm|ms|br/min)'").hasMatch(src), isFalse); + }); +} From 0635108513f8a34b945c09a1c3b22812c200c66b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 54/72] add the coach answer text and band rename dialog to the gallery both are plain widgets, and the gallery check fails on any component it does not hold. --- lib/ui2/profile/gallery.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 53cf6fc1f..6234e09f0 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -395,6 +395,15 @@ Map _coachFigureCases() => { 'skin_temp_z': -0.4, }, ), + // A reply carrying a markdown image: it has to draw the inert placeholder, + // never a fetched picture. + 'coach_answer_text': const CoachAnswerText( + 'Your **resting HR** sat 3 bpm under your usual this week.\n\n' + '![chart](https://example.invalid/hr.png)\n\n' + '- sleep held at 7 h 12 min\n- strain eased off midweek', + ), + // The band rename sheet's body, outside the dialog route that hosts it. + 'rename_band_dialog': const RenameBandDialog(current: 'WHOOP 4A1B2C'), }; /// Charts, framed. Every one of these is captured with the thing that was From 150d2972dff0da35d42fd68dfc51bd2979bd58b8 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 55/72] russian arb was missing placeholder types for three messages homeDayLoadFailedTitle, sleepDetailPointsDelta and reportDeleteConfirmTitle had russian text but no placeholder metadata. --- lib/l10n/app_ru.arb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4cef25d17..c6fa2177b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3363,6 +3363,14 @@ "homeLoadFailedTitle": "Не удалось прочитать сегодняшний день", "homeLoadFailedBody": "Сохранённый день не загрузился. Ничего не удалено: это ошибка чтения, а не отсутствие данных.", "homeDayLoadFailedTitle": "Не удалось прочитать {day}", + "@homeDayLoadFailedTitle": { + "placeholders": { + "day": { + "type": "String", + "example": "Wednesday, 15 October" + } + } + }, "wellnessLoadFailedTitle": "Не удалось прочитать", "wellnessLoadFailedBody": "Не удалось прочитать сохранённые данные. Ничего не удалено — это сбой чтения, а не отсутствие данных.", "homeTryAgain": "Повторить", @@ -4019,6 +4027,13 @@ "sleepDetailLowerThanUsual": "ниже обычного", "sleepDetailHigherThanUsual": "выше обычного", "sleepDetailPointsDelta": "на {n} п.п.", + "@sleepDetailPointsDelta": { + "placeholders": { + "n": { + "type": "String" + } + } + }, "sleepDetailFellAsleep": "Засыпание", "sleepDetailEarlierThanUsual": "раньше обычного", "sleepDetailLaterThanUsual": "позже обычного", @@ -7523,6 +7538,13 @@ "reportDelete": "Удалить", "reportDeleteFailed": "Не удалось удалить. Попробуйте позже.", "reportDeleteConfirmTitle": "Удалить сообщение {id}?", + "@reportDeleteConfirmTitle": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "reportDeleteConfirmBody": "Оно сразу удаляется с сервера, и открыть его по ID больше не получится. Отменить нельзя.", "reportSaveFailedTitle": "Не удалось поделиться сообщением", "reportSaveFailedBody": "Не удалось собрать или передать файл. Тикет не создан.", From af3e0c749c67acf8d6b71d49cb9d40f06354dd6c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 56/72] step goal test looked up buttons whose labels merge into the gauge card the edit and save labels end up in the card's single semantics node, so the lookup found nothing. the test taps the goal text and the check icon instead. --- test/ui2_step_goal_unreadable_test.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/ui2_step_goal_unreadable_test.dart b/test/ui2_step_goal_unreadable_test.dart index 79f2e8973..bb78e3d61 100644 --- a/test/ui2_step_goal_unreadable_test.dart +++ b/test/ui2_step_goal_unreadable_test.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; @@ -35,13 +36,16 @@ Future _pumpSteps(WidgetTester t) async { } Future _openEditorAndType(WidgetTester t, String text) async { - final edit = find.bySemanticsLabel('Edit daily step goal'); + // The edit and save buttons' labels merge into the gauge card's one + // semantics node ("60% | Edit daily step goal | Goal | ..."), and tapping + // that node lands on the ring, so the taps go to the buttons themselves. + final edit = find.text('10,000 steps'); await t.ensureVisible(edit); await t.tap(edit); await t.pumpAndSettle(); await t.enterText(find.byType(TextField), text); await t.pump(); - await t.tap(find.bySemanticsLabel('Save step goal')); + await t.tap(find.byIcon(LucideIcons.check)); await t.pumpAndSettle(); } From 8f63f61131065c5268e93574461c85909adce3de Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 57/72] sleep scrub temp used a '.' decimal in every locale the signed reading went through toStringAsFixed while the readings next to it use the display locale's decimal mark. --- lib/ui2/screens/sleep_detail.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 333d96265..06c168298 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -421,11 +421,12 @@ String? sleepDayAfterOverride( /// sensor's raw count minus the day's median, already rounded to one decimal: /// there is no °C behind it. Printed bare as "-12.40" beside "52 bpm" it read /// as a twelve-degree drop, so it carries an explicit sign and no padding -/// past the precision it actually has. +/// past the precision it actually has, with the display locale's decimal mark +/// like the readings beside it. @visibleForTesting String sleepScrubTempDelta(num v) { - final s = v.abs().toStringAsFixed(1); - if (s == '0.0') return '±0.0'; + final s = displayFixed(v.abs(), 1); + if ((v.abs() * 10).round() == 0) return '±$s'; return v < 0 ? '−$s' : '+$s'; } From bac98e975cae466057e61440984f0052fed95131 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 21:30:50 +0530 Subject: [PATCH 58/72] journal ai sheet test tapped the english link in russian the talk it through with ai link is localized now, so the test taps it in the locale under test. --- test/journal_ai_sheet_a11y_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/journal_ai_sheet_a11y_test.dart b/test/journal_ai_sheet_a11y_test.dart index cbf15ea7b..c8148c989 100644 --- a/test/journal_ai_sheet_a11y_test.dart +++ b/test/journal_ai_sheet_a11y_test.dart @@ -39,7 +39,9 @@ Future _openSheet(WidgetTester t, Locale locale) async { ), )); await t.pumpAndSettle(); - await t.tap(find.text('Talk it through with AI')); + // The entry link is localized, so tap it in the locale under test. + await t.tap(find.text( + lookupAppLocalizations(locale).journalComposeTalkItThroughWithAi)); await t.pumpAndSettle(); } From eb68e0b611b9791b96987fcb1593727e8021b86b Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Wed, 28 Oct 2026 22:07:16 +0530 Subject: [PATCH 59/72] homeSwitcherDay is shared with the health tab now; sub-tab test off the deprecated matcher --- lib/ui2/screens/home_screen.dart | 1 - test/ui2_subtabs_selected_semantics_test.dart | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 47214c546..66ffc5f1f 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1554,7 +1554,6 @@ class HomeScreen extends StatefulWidget { /// never disposed — a literal today's date stored by "Back to today" turned /// into yesterday at midnight, and the tab silently became a past-day view /// (no plan, no alarm, pull-to-refresh no longer synced). -@visibleForTesting String? homeSwitcherDay(String day, {DateTime? now}) => day == todayLabel(now) ? null : day; diff --git a/test/ui2_subtabs_selected_semantics_test.dart b/test/ui2_subtabs_selected_semantics_test.dart index 284b073c4..8b3a094dd 100644 --- a/test/ui2_subtabs_selected_semantics_test.dart +++ b/test/ui2_subtabs_selected_semantics_test.dart @@ -22,12 +22,12 @@ void main() { expect( t.getSemantics(find.text('30 days')), - containsSemantics(isSelected: true, isButton: true), + isSemantics(isSelected: true, isButton: true), ); for (final other in const ['7 days', '90 days']) { expect( t.getSemantics(find.text(other)), - containsSemantics(isSelected: false, isButton: true), + isSemantics(isSelected: false, isButton: true), ); } handle.dispose(); @@ -38,14 +38,14 @@ void main() { await t.pumpWidget(_harness(SubTabs(items, 0, (_) {}))); await t.pumpAndSettle(); expect(t.getSemantics(find.text('7 days')), - containsSemantics(isSelected: true)); + isSemantics(isSelected: true)); await t.pumpWidget(_harness(SubTabs(items, 2, (_) {}))); await t.pumpAndSettle(); expect(t.getSemantics(find.text('7 days')), - containsSemantics(isSelected: false)); + isSemantics(isSelected: false)); expect(t.getSemantics(find.text('90 days')), - containsSemantics(isSelected: true)); + isSemantics(isSelected: true)); handle.dispose(); }); @@ -56,7 +56,7 @@ void main() { await t.pumpAndSettle(); expect( t.getSemantics(find.text('30 days')), - containsSemantics(isSelected: false), + isSemantics(isSelected: false), ); handle.dispose(); }); From 45646c343439d8701b2c857cb1709afb0a4ef231 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:26:36 +0530 Subject: [PATCH 60/72] say what the report encrypts, name percentage points, and word the coach model as not yet saved --- lib/l10n/app_de.arb | 4 ++-- lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_es.arb | 4 ++-- lib/l10n/app_fr.arb | 6 +++--- lib/l10n/app_hi.arb | 4 ++-- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/ui2/screens/sleep_detail.dart | 2 +- test/sleep_efficiency_delta_l10n_test.dart | 6 +++--- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 25c29aff2..c06f9cb27 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -916,7 +916,7 @@ "coachLocalSub": "In diesem Netzwerk. Nichts verlässt deinen Rechner.", "coachMenuSemantic": "Chats und KI-Einstellungen", "coachModelHint": "Suchen oder ID eingeben", - "coachModelSelected": "Gespeichertes Modell: {model}", + "coachModelSelected": "Beim Speichern wird {model} verwendet", "coachModelLabel": "Modell", "coachModelsFound": "{n, plural, one{{n} Modell. Zum Auswählen antippen.} other{{n} Modelle. Zum Auswählen antippen.}}", "coachNavTitle": "Coach", @@ -2833,7 +2833,7 @@ "settingsCycleTrackingRowSubHealth": "Fügt „Zyklus“ unter „Gesundheit“ hinzu. Ausgeschaltet wird es ausgeblendet, bisherige Einträge bleiben erhalten", "profileSetupCycleSub": "Trag deine Periode unter „Gesundheit“ ein. Das lässt sich später ändern.", "settingsReportIssueRowTitle": "Problem melden", - "settingsReportIssueRowSub": "Sendet, was du auswählst, verschlüsselt an die Entwickler", + "settingsReportIssueRowSub": "Sendet, was du auswählst, an die Entwickler; deine Daten werden verschlüsselt, die Angaben zur Meldung nicht", "reportNavTitle": "Problem melden", "reportCategoryGroup": "Worum es geht", "reportCategorySync": "Synchronisierung", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 84340b2df..89ad2d42a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8752,7 +8752,7 @@ "@sleepDetailHigherThanUsual": { "description": "Verdict phrase: sleep efficiency was higher than usual." }, - "sleepDetailPointsDelta": "{n} points", + "sleepDetailPointsDelta": "{n} percentage points", "@sleepDetailPointsDelta": { "description": "Verdict prefix: how far sleep efficiency was from the usual median, in percentage points; followed by the lower/higher-than-usual phrase.", "placeholders": { @@ -15604,7 +15604,7 @@ "@settingsReportIssueRowTitle": { "description": "Settings row that opens the issue report screen." }, - "settingsReportIssueRowSub": "Sends what you choose to the maintainers, encrypted", + "settingsReportIssueRowSub": "Sends what you choose to the maintainers; your data is encrypted, the report details are not", "@settingsReportIssueRowSub": { "description": "Settings row subtitle for Report an issue." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cf2b5a1ed..20537115e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1715,7 +1715,7 @@ "sleepDetailAsleepWhileInBed": "Dormido mientras estabas en cama", "sleepDetailLowerThanUsual": "más bajo de lo habitual", "sleepDetailHigherThanUsual": "más alto de lo habitual", - "sleepDetailPointsDelta": "{n} puntos", + "sleepDetailPointsDelta": "{n} puntos porcentuales", "sleepDetailFellAsleep": "Te dormiste", "sleepDetailEarlierThanUsual": "más temprano de lo habitual", "sleepDetailLaterThanUsual": "más tarde de lo habitual", @@ -2745,7 +2745,7 @@ "settingsCycleTrackingRowSubHealth": "Agrega Ciclo a Salud. Desactivado lo oculta y conserva todo lo ya registrado", "profileSetupCycleSub": "Registra tu periodo en Salud. Puedes cambiarlo más tarde.", "settingsReportIssueRowTitle": "Informar de un problema", - "settingsReportIssueRowSub": "Envía lo que elijas a los responsables, cifrado", + "settingsReportIssueRowSub": "Envía lo que elijas a los responsables; tus datos van cifrados, los detalles del informe no", "reportNavTitle": "Informar de un problema", "reportCategoryGroup": "De qué se trata", "reportCategorySync": "Sincronización", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 096f14bd5..2d24ffb0e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -887,7 +887,7 @@ "coachLocalSub": "Sur ce réseau. Rien ne quitte votre machine.", "coachMenuSemantic": "Discussions et réglages IA", "coachModelHint": "chercher, ou saisir un identifiant", - "coachModelSelected": "Modèle enregistré : {model}", + "coachModelSelected": "Modèle qui sera enregistré : {model}", "coachModelLabel": "Modèle", "coachModelsFound": "{n, plural, one{{n} modèle. Touchez-en un.} other{{n} modèles. Touchez-en un.}}", "coachNavTitle": "Coach", @@ -1705,7 +1705,7 @@ "sleepDetailAsleepWhileInBed": "Endormi pendant le temps au lit", "sleepDetailLowerThanUsual": "plus bas que d'habitude", "sleepDetailHigherThanUsual": "plus haut que d'habitude", - "sleepDetailPointsDelta": "{n} points", + "sleepDetailPointsDelta": "{n} points de pourcentage", "sleepDetailFellAsleep": "Endormissement", "sleepDetailEarlierThanUsual": "plus tôt que d'habitude", "sleepDetailLaterThanUsual": "plus tard que d'habitude", @@ -2716,7 +2716,7 @@ "settingsCycleTrackingRowSubHealth": "Ajoute Cycle à Santé. Désactivé le masque et conserve tout ce qui est déjà enregistré", "profileSetupCycleSub": "Enregistrez vos règles dans Santé. Vous pourrez changer cela plus tard.", "settingsReportIssueRowTitle": "Signaler un problème", - "settingsReportIssueRowSub": "Envoie ce que vous choisissez aux mainteneurs, chiffré", + "settingsReportIssueRowSub": "Envoie ce que vous choisissez aux mainteneurs ; vos données sont chiffrées, les détails du signalement non", "reportNavTitle": "Signaler un problème", "reportCategoryGroup": "Sujet", "reportCategorySync": "Synchronisation", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7760eef43..55fa376d9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1705,7 +1705,7 @@ "sleepDetailAsleepWhileInBed": "बिस्तर पर रहते हुए सोया हुआ", "sleepDetailLowerThanUsual": "सामान्य से कम", "sleepDetailHigherThanUsual": "सामान्य से ज़्यादा", - "sleepDetailPointsDelta": "{n} अंक", + "sleepDetailPointsDelta": "{n} प्रतिशत अंक", "sleepDetailFellAsleep": "नींद आई", "sleepDetailEarlierThanUsual": "सामान्य से जल्दी", "sleepDetailLaterThanUsual": "सामान्य से देर से", @@ -2716,7 +2716,7 @@ "settingsCycleTrackingRowSubHealth": "स्वास्थ्य में साइकिल जोड़ता है। बंद करने पर यह छिप जाता है और पहले से लॉग किया गया सब कुछ बना रहता है", "profileSetupCycleSub": "अपना पीरियड स्वास्थ्य में लॉग करें। इसे आप बाद में बदल सकते हैं।", "settingsReportIssueRowTitle": "समस्या की रिपोर्ट करें", - "settingsReportIssueRowSub": "आप जो चुनें उसे एन्क्रिप्ट करके मेंटेनर्स को भेजता है", + "settingsReportIssueRowSub": "आप जो चुनें उसे मेंटेनर्स को भेजता है; आपका डेटा एन्क्रिप्ट होता है, रिपोर्ट का विवरण नहीं", "reportNavTitle": "समस्या की रिपोर्ट करें", "reportCategoryGroup": "यह किस बारे में है", "reportCategorySync": "सिंक", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 761dcabb9..fa421016d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7473,7 +7473,7 @@ "deviceUseSheetConfirm": "Использовать", "deviceUseSheetCancel": "Не сейчас", "settingsReportIssueRowTitle": "Сообщить о проблеме", - "settingsReportIssueRowSub": "Отправляет выбранное вами разработчикам в зашифрованном виде", + "settingsReportIssueRowSub": "Отправляет выбранное вами разработчикам; ваши данные шифруются, сведения о сообщении — нет", "reportNavTitle": "Сообщить о проблеме", "reportCategoryGroup": "О чём сообщение", "reportCategorySync": "Синхронизация", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 45cc07189..f18b09f5f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2716,7 +2716,7 @@ "settingsCycleTrackingRowSubHealth": "在“健康”中添加“周期”。关闭后会隐藏,但已记录的内容仍会保留", "profileSetupCycleSub": "在“健康”中记录经期。之后可以更改。", "settingsReportIssueRowTitle": "报告问题", - "settingsReportIssueRowSub": "将你选择的内容加密发送给维护者", + "settingsReportIssueRowSub": "将你选择的内容发送给维护者;你的数据会加密,报告详情不会", "reportNavTitle": "报告问题", "reportCategoryGroup": "问题类型", "reportCategorySync": "同步", diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 6fa4c0875..9d3d7b6df 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -146,7 +146,7 @@ String _pct(double v) => '${v.round()}%'; /// glued in front of the localized lower/higher phrase, so an English unit /// here left every other locale reading a half-English verdict. String sleepEfficiencyDelta(AppLocalizations? l, double v) => - l?.sleepDetailPointsDelta('${v.round()}') ?? '${v.round()} points'; + l?.sleepDetailPointsDelta('${v.round()}') ?? '${v.round()} percentage points'; /// A reading with its unit, in the display locale: metric detail prints the /// same number through [displayFixed] and a translated unit, so a raw diff --git a/test/sleep_efficiency_delta_l10n_test.dart b/test/sleep_efficiency_delta_l10n_test.dart index 097867ec9..60802b6aa 100644 --- a/test/sleep_efficiency_delta_l10n_test.dart +++ b/test/sleep_efficiency_delta_l10n_test.dart @@ -9,10 +9,10 @@ import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; void main() { - test('english keeps points', () { + test('english names percentage points', () { final l = lookupAppLocalizations(const Locale('en')); - expect(sleepEfficiencyDelta(l, 11.6), '12 points'); - expect(sleepEfficiencyDelta(null, 11.6), '12 points'); + expect(sleepEfficiencyDelta(l, 11.6), '12 percentage points'); + expect(sleepEfficiencyDelta(null, 11.6), '12 percentage points'); }); for (final code in ['de', 'es', 'ru', 'hi', 'zh']) { From 7b1bb0ddc4ef6a9b0bb33418050d400ea6c571ac Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:27:39 +0530 Subject: [PATCH 61/72] translate the poster's elevation caption in feet too --- lib/l10n/app_en.arb | 9 +++++++ lib/l10n/app_ru.arb | 1 + lib/ui2/activity/share.dart | 7 ++++-- test/share_elevation_caption_test.dart | 33 ++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/share_elevation_caption_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 89ad2d42a..57857ce09 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12829,6 +12829,15 @@ } } }, + "supplementClimbedExportFeet": "+{ft} ft elevation", + "@supplementClimbedExportFeet": { + "description": "Share poster caption: elevation gained, in feet for imperial units", + "placeholders": { + "ft": { + "type": "String" + } + } + }, "supplementLapsExport": "{n} laps", "@supplementLapsExport": { "description": "Presentation-only text; interpolated values remain unchanged.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fa421016d..f5e473ab4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -5893,6 +5893,7 @@ } }, "supplementClimbedExport": "+{m} м высоты", + "supplementClimbedExportFeet": "+{ft} фт высоты", "@supplementClimbedExport": { "placeholders": { "m": { diff --git a/lib/ui2/activity/share.dart b/lib/ui2/activity/share.dart index 53e62a49b..3c32c32b3 100644 --- a/lib/ui2/activity/share.dart +++ b/lib/ui2/activity/share.dart @@ -489,9 +489,12 @@ List<(String, String)> _available(ActivityResult r, [UnitsController? u]) => [ String localizedExportCaption(BuildContext c, ActivityResult r, String text) { final l = AppLocalizations.of(c); if (text == r.activity.name) return r.activity.displayName(c); - final elevation = RegExp(r'^\+(.*?) m elevation$').firstMatch(text); + final elevation = RegExp(r'^\+(.*?) (m|ft) elevation$').firstMatch(text); if (elevation != null) { - return l?.supplementClimbedExport(elevation[1]!) ?? text; + return (elevation[2] == 'ft' + ? l?.supplementClimbedExportFeet(elevation[1]!) + : l?.supplementClimbedExport(elevation[1]!)) ?? + text; } final laps = RegExp(r'^(.*?) laps$').firstMatch(text); if (laps != null) return l?.supplementLapsExport(laps[1]!) ?? text; diff --git a/test/share_elevation_caption_test.dart b/test/share_elevation_caption_test.dart new file mode 100644 index 000000000..889a27ac4 --- /dev/null +++ b/test/share_elevation_caption_test.dart @@ -0,0 +1,33 @@ +// The poster's elevation caption is built in the user's units, so an imperial +// "+394 ft elevation" has to be translated just like the metric one. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/activity/share.dart' + show localizedExportCaption; +import 'package:openstrap_edge/ui2/activity/summary.dart'; + +void main() { + testWidgets('both elevation units are translated', (tester) async { + final r = ActivityResult( + allActivities.first, + start: DateTime(2026, 5, 20, 7, 15), + duration: const Duration(minutes: 45), + ); + late BuildContext ctx; + await tester.pumpWidget(MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (c) { + ctx = c; + return const SizedBox(); + }), + )); + expect(localizedExportCaption(ctx, r, '+120 m elevation'), '+120 м высоты'); + expect(localizedExportCaption(ctx, r, '+394 ft elevation'), + '+394 фт высоты'); + }); +} From 43013d7569698521c84f3bf900d2ade7e4b733c1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:28:35 +0530 Subject: [PATCH 62/72] keep a held import report through a cancelled retry, and continue only from below it --- lib/ui2/onboarding/welcome.dart | 22 +++++++++++++++------- test/welcome_import_report_hold_test.dart | 2 ++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index 9755ceccc..e290466d8 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -289,10 +289,13 @@ class _WelcomeScreenState extends State { .toList(); if (paths.isEmpty) return; // cancelled — not a failure, say nothing + // A retry after a partial import keeps what is held: that history has + // already landed, so cancelling or failing the retry must not take the + // report and Continue away or put "new" on record. + final held = _outcome; setState(() { _busy = true; _outcome = null; - _holding = false; }); try { final outcome = await runImport(app, paths, @@ -302,10 +305,11 @@ class _WelcomeScreenState extends State { if (!mounted) return; setState(() { _outcome = outcome; - _holding = hold; + _holding = _holding || hold; }); } on PassphraseCancelled { // Closing the prompt is a decision, not a failure. Say nothing. + if (mounted) setState(() => _outcome = held); } catch (e) { if (mounted) { setState(() => @@ -677,11 +681,15 @@ class WelcomeView extends StatelessWidget { Pill(l?.pillLocalNoCloud ?? 'Local · no cloud', C.green, icon: LucideIcons.shieldCheck), const SizedBox(height: S.x8), - BigButton(l?.welcomeSetUpMyBand ?? 'Set up my band', - icon: LucideIcons.bluetooth, - color: C.green, - onTap: busy ? null : onNew), - const SizedBox(height: S.x3), + // With a report to read, the only way on is Continue below it, so + // nobody leaves before seeing what was rejected. + if (onContinue == null) ...[ + BigButton(l?.welcomeSetUpMyBand ?? 'Set up my band', + icon: LucideIcons.bluetooth, + color: C.green, + onTap: busy ? null : onNew), + const SizedBox(height: S.x3), + ], // One primary path. Importing first is a real choice but a rarer // one, so it is a text link rather than a second button of equal // weight beside "Set up my band". diff --git a/test/welcome_import_report_hold_test.dart b/test/welcome_import_report_hold_test.dart index d14ffc56f..9848fa084 100644 --- a/test/welcome_import_report_hold_test.dart +++ b/test/welcome_import_report_hold_test.dart @@ -52,6 +52,8 @@ void main() { expect(app.route, AppRoute.welcome); expect(find.byType(ImportReport), findsOneWidget); expect(find.textContaining('Re-analyze everything'), findsOneWidget); + // The only way on is Continue, below the report. + expect(find.text('Set up my band'), findsNothing); await tester.scrollUntilVisible(find.text('Continue'), 200); await tester.tap(find.text('Continue')); From 981cab386ce79ebeda4a1835322f4b83320f47b5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:29:22 +0530 Subject: [PATCH 63/72] lock the composer while a chat opens or deletes, and drop a model list from an endpoint already edited away --- lib/ui2/screens/coach.dart | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/ui2/screens/coach.dart b/lib/ui2/screens/coach.dart index 72ec484d9..75e1410d2 100644 --- a/lib/ui2/screens/coach.dart +++ b/lib/ui2/screens/coach.dart @@ -312,14 +312,27 @@ class _CoachScreenState extends State { setState(_items.clear); } + // Opening or deleting a chat swaps the engine's open chat, so the composer + // stays locked until that is done, the same way the menu does during a send. + Future _sessionChange(Future Function() change) async { + setState(() => _busy = true); + try { + await change(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + Future _openSession(String id) async { if (_busy) return; - await _engine?.openSession(id); + final engine = _engine; + if (engine == null) return; + await _sessionChange(() => engine.openSession(id)); if (!mounted) return; setState(() { _items ..clear() - ..addAll(_engine?.transcript ?? const []); + ..addAll(engine.transcript); }); _scrollDown(); } @@ -419,7 +432,7 @@ class _CoachScreenState extends State { onDelete: (id) async { if (_busy) return; final wasOpen = id == engine.sessionId; - await engine.deleteSession(id); + await _sessionChange(() => engine.deleteSession(id)); setSheet(() {}); if (wasOpen && mounted) setState(_items.clear); }, @@ -1020,9 +1033,12 @@ class _CoachSetupState extends State { _loading = true; _msg = null; }); + // A reply for an endpoint the user has since edited away from would list + // models the new one may not serve. + final asked = _base.text; try { - final ids = await CoachEngine.fetchModels(_base.text, _key.text); - if (!mounted) return; + final ids = await CoachEngine.fetchModels(asked, _key.text); + if (!mounted || _base.text != asked) return; final l = AppLocalizations.of(context); setState(() { _models = ids; @@ -1032,7 +1048,7 @@ class _CoachSetupState extends State { : (l?.coachModelsFound(ids.length) ?? '${ids.length} models. Tap one.'); }); } catch (e) { - if (!mounted) return; + if (!mounted || _base.text != asked) return; final l = AppLocalizations.of(context); setState( () => _msg = e is CoachException From f5134e4a93a50879c5868393adf3eb632df7d85f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:30:08 +0530 Subject: [PATCH 64/72] drop the old day's vitals when a new day is picked --- lib/ui2/screens/health_screen.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index bbb48c131..610e57e88 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -596,7 +596,13 @@ class _HealthScreenState extends State with RevisionReload { void _goVitalsDay(String day) { // Stored as null on today, as Home does: Health is kept alive by the shell // and a literal date would pin Vitals to it once the calendar moves on. - setState(() => _vDay = homeSwitcherDay(day)); + // The shown readings belong to the old day: drop them, so neither the + // load nor a failed read can show them under the new one. + setState(() { + _vDay = homeSwitcherDay(day); + _v = null; + _vFailed = false; + }); _loadVitals(force: true); } From fefc8b4c8d69f919ea4f1a58b835a989a1d9d07f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:32:09 +0530 Subject: [PATCH 65/72] sleep screen: a failed read names the night asked for, shows no plan under it, and retries without the old night also keeps the edited night pinned when the reload after a correction fails. --- lib/ui2/screens/sleep_detail.dart | 18 +++++++++--- test/ui2_sleep_detail_load_failure_test.dart | 31 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 9d3d7b6df..5dcd71e3d 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -488,7 +488,10 @@ class _SleepDetailState extends State with RevisionReload { child: Text(sub, style: F.over.copyWith(color: p.ink3)), ), ...body, - if (_d?.isLatestNight ?? _day == null) ...widget.todayFooter, + // Not under a read error: `_d` may still be the latest night while + // the asked-for one failed. + if (!_failed && (_d?.isLatestNight ?? _day == null)) + ...widget.todayFooter, ...widget.footer, ], ), @@ -668,7 +671,9 @@ class _SleepDetailState extends State with RevisionReload { if (_failed) { // `_d` may still hold the night this screen stepped away from; only its // day list is used, so the stepper stays and the day can be walked off. - final shown = _day ?? d.day; + // A step to the latest night stores null in `_day`, so that night is + // named from `latestDay`, not from the night stepped away from. + final shown = _day ?? d.latestDay ?? d.day; return _frame(c, title, [ ...dayNavRow(shown, d.days, _goDay), const SizedBox(height: S.x2), @@ -683,7 +688,9 @@ class _SleepDetailState extends State with RevisionReload { fix: l?.homeTryAgain ?? 'Try again', icon: LucideIcons.databaseZap, onFix: () { - setState(() => (_loading = true, _failed = false)); + // `_d` is the night stepped away from; drawing it under the asked + // day while the retry runs would show the wrong readings. + setState(() => (_loading = true, _failed = false, _d = null)); _load(); }, ), @@ -1202,7 +1209,10 @@ class _SleepDetailState extends State with RevisionReload { if (_day == null && _d?.day == day) _day = day; if (mounted) await _load(); if (!mounted) return; - _day = sleepDayAfterOverride(_day, day, _d?.latestDay); + // Only a reload that worked says which night is latest now; after a + // failed one `_d` is the old result and releasing the pin could make a + // retry open another night. + if (!_failed) _day = sleepDayAfterOverride(_day, day, _d?.latestDay); // EVERY failure below this screen is silent: the forced re-derive catches // and logs its own throw, and it returns at its first line when another // re-analysis is already running. Both leave the write in the database and diff --git a/test/ui2_sleep_detail_load_failure_test.dart b/test/ui2_sleep_detail_load_failure_test.dart index 4fe450abd..375603bfd 100644 --- a/test/ui2_sleep_detail_load_failure_test.dart +++ b/test/ui2_sleep_detail_load_failure_test.dart @@ -128,6 +128,37 @@ void main() { // The stepper stays so the day can be walked off. expect(find.byType(DayNav), findsOneWidget); }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets( + 'a failed step back to the latest night names that night, ' + 'with no plan under the error, and retries without the old night', + (t) async { + final repo = _FlakyRepo(0); + await _pump( + t, + repo, + const SleepDetail( + day: '2026-05-19', todayFooter: [Text('TONIGHT PLAN')])); + expect(t.widget(find.byType(DayNav)).day, '2026-05-19'); + expect(find.text('TONIGHT PLAN'), findsNothing); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-22'); + await t.pump(); + await _settle(t); + + expect(find.textContaining('could not be read'), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-22'); + expect(find.text('TONIGHT PLAN'), findsNothing); + + // Hold the retry's read so the frame during it can be inspected. + repo.failures = 0; + await t.tap(find.text('Try again')); + await t.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await _settle(t); + expect(find.text('No night to show'), findsOneWidget); + }, timeout: const Timeout(Duration(seconds: 60))); }); group('the day timeline', () { From 615574ff84b6c7873eaa0cea064e886b9ef42fc5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:33:13 +0530 Subject: [PATCH 66/72] a failed session-history read shows the retry card, not an empty history --- lib/ui2/screens/workout_screen.dart | 6 +++--- test/ui2_workout_first_load_test.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 7ac6c244d..ec0c8308e 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -2055,8 +2055,10 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { trimp = List.filled(7, null); } + // Not caught: a failed read of the session history has to reach the + // retry card, not draw as "No sessions recorded yet". final past = <_PastWorkout>[]; - try { + { final rows = (await repo.getWorkouts(range: 'month'))['workouts']; if (rows is List) { for (final r in rows) { @@ -2089,8 +2091,6 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { )); } } - } catch (_) { - // leave `past` as-is } // Workouts another app recorded, on the SAME window the band's own list // uses. The store is read 90 days back (30 on Android) because that is the diff --git a/test/ui2_workout_first_load_test.dart b/test/ui2_workout_first_load_test.dart index c4da59178..d33996b23 100644 --- a/test/ui2_workout_first_load_test.dart +++ b/test/ui2_workout_first_load_test.dart @@ -26,6 +26,15 @@ class _StalledRepo extends LocalRepository { Future> getProfile() => profile.future; } +/// A repo whose session-history read fails. +class _BrokenHistoryRepo extends LocalRepository { + @override + Future> getProfile() async => {}; + @override + Future> getWorkouts({String range = 'month'}) async => + throw const FormatException('database is locked'); +} + Widget _app(AppState app) => MaterialApp( theme: buildTheme(Brightness.light), home: MultiProvider( @@ -69,4 +78,22 @@ void main() { expect(find.text('No sessions recorded yet'), findsNothing); expect(find.byType(CircularProgressIndicator), findsOneWidget); }); + + testWidgets('a failed history read is a retry card, not an empty history', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _BrokenHistoryRepo(); + + await t.pumpWidget(_app(app)); + await t.pumpAndSettle(); + await t.tap(find.text('History')); + await t.pumpAndSettle(); + expect(find.text('No sessions recorded yet'), findsNothing); + expect(find.text('Activity could not be read'), findsOneWidget); + }); } From cf90e073fa2598e1e60ed08203962abbb5846aea Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:33:57 +0530 Subject: [PATCH 67/72] read the old step goal right before the merge, so overlapping updates both refresh home --- lib/state/app_state.dart | 4 +++- test/app_state_regressions_test.dart | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7bae7c973..a2fb7c240 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1200,11 +1200,13 @@ class AppState extends ChangeNotifier { int? resetGeneration, }) async { final resetGen = resetGeneration ?? ResetGate.generation; - final goalBefore = user?['step_goal']; final prefs = await SharedPreferences.getInstance(); if (ResetGate.changedSince(resetGen)) { throw StateError('profile not saved: the app data was reset'); } + // Read right before the merge, after the await: an overlapping update can + // change the goal while this one waits. + final goalBefore = user?['step_goal']; user = {...?user, ...fields}; await prefs.setString(_kProfile, jsonEncode(user)); notifyListeners(); diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index 71386febc..240be8bc8 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -625,6 +625,23 @@ void main() { expect(app.insightsRevision.value, before); }); + + test('overlapping updates that change then restore the goal both bump', + () async { + final app = AppState.forTesting(); + addTearDown(app.dispose); + await app.updateProfile({'step_goal': 8000}); + final before = app.insightsRevision.value; + + // Both start before either has merged. + await Future.wait([ + app.updateProfile({'step_goal': 10000}), + app.updateProfile({'step_goal': 8000}), + ]); + + expect(app.user!['step_goal'], 8000); + expect(app.insightsRevision.value, before + 2); + }); }); // ── 10. dispose must release EVERYTHING AppState owns ────────────────────── From d11eeaea9d4789c5c2d997bac7bdaf127a87bcc5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 10:58:01 +0530 Subject: [PATCH 68/72] a cleared profile field in setup is saved as cleared the fields open filled from the stored profile and the save merges into it, so a field left out kept its old value feeding calories and load. --- lib/ui2/onboarding/profile_setup.dart | 14 +++++++------ test/ui2_router_test.dart | 30 ++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 2c403370e..087fb0d3a 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -125,14 +125,16 @@ class _ProfileSetupViewState extends State { super.dispose(); } - /// Only what was actually entered. A blank field writes nothing, so the - /// dependent metric abstains rather than scoring somebody else's body. The - /// fields are typed in the units on their labels and stored in metric. + /// Only what was actually entered. A blank field writes an explicit null, + /// not nothing: the fields open filled from the stored profile, and the save + /// merges into it, so leaving a cleared one out kept the old value feeding + /// calories and load. With null the dependent metric abstains. The fields + /// are typed in the units on their labels and stored in metric. Map _fields() => { if (_sex != null) 'sex': _sex, - if (Typed.of(_age.text).value case final v?) 'age': v.round(), - 'height_cm': ?_heightCm(), - 'weight_kg': ?_weightKg(), + 'age': Typed.of(_age.text).value?.round(), + 'height_cm': _heightCm(), + 'weight_kg': _weightKg(), }; /// Continue, unless something typed cannot be read — a typo is not a blank, diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 45b15f9e8..4798b04fc 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -322,11 +322,11 @@ void main() { expect(saved, isNotNull); expect(saved!['sex'], 'f'); - // The three optional fields were left blank, so they are ABSENT — not + // The three optional fields were left blank, so they are null — not // zero, and not a default body. - expect(saved!.containsKey('age'), isFalse); - expect(saved!.containsKey('height_cm'), isFalse); - expect(saved!.containsKey('weight_kg'), isFalse); + expect(saved!['age'], isNull); + expect(saved!['height_cm'], isNull); + expect(saved!['weight_kg'], isNull); }); testWidgets('what is typed is what is written', (tester) async { @@ -346,7 +346,27 @@ void main() { expect(saved!['sex'], 'm'); expect(saved!['age'], 34); expect(saved!['weight_kg'], 78.4); - expect(saved!.containsKey('height_cm'), isFalse); + expect(saved!['height_cm'], isNull); + }); + + testWidgets('clearing a stored value writes null over it', (tester) async { + _tallView(tester); + Map? saved; + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ProfileSetupView( + initial: const {'sex': 'm', 'weight_kg': 78.4, 'age': 34}, + onSave: (f) async => saved = f), + )); + await tester.enterText(find.byType(TextField).at(2), ''); + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pump(); + + // Merged into the stored profile, a missing key would keep 78.4. + expect(saved!.containsKey('weight_kg'), isTrue); + expect(saved!['weight_kg'], isNull); + expect(saved!['age'], 34); }); testWidgets('a number it cannot read stops the save instead of dropping it', From a1a175adba86e488c4908d259b7f1e232b1401b5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 11:00:47 +0530 Subject: [PATCH 69/72] give the russian feet caption its placeholder type --- lib/l10n/app_ru.arb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f5e473ab4..571765f1b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -5893,7 +5893,6 @@ } }, "supplementClimbedExport": "+{m} м высоты", - "supplementClimbedExportFeet": "+{ft} фт высоты", "@supplementClimbedExport": { "placeholders": { "m": { @@ -5901,6 +5900,14 @@ } } }, + "supplementClimbedExportFeet": "+{ft} фт высоты", + "@supplementClimbedExportFeet": { + "placeholders": { + "ft": { + "type": "String" + } + } + }, "supplementLapsExport": "Кругов: {n}", "@supplementLapsExport": { "placeholders": { From 67d1d9a1e658f40ca4a0c3be73e9eed71f098ddf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 11:30:23 +0530 Subject: [PATCH 70/72] keep the vitals day stepper when a picked day fails to read --- lib/ui2/screens/health_screen.dart | 34 ++++++++++---- test/vitals_day_failure_nav_test.dart | 66 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 test/vitals_day_failure_nav_test.dart diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index 610e57e88..6221660a7 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -517,6 +517,7 @@ class _HealthScreenState extends State with RevisionReload { super.initState(); _d = widget.data; _v = widget.vitals; + _vDays = widget.vitals?.days; _l = widget.labs; _e = widget.explore; if (widget.data != null) { @@ -579,6 +580,11 @@ class _HealthScreenState extends State with RevisionReload { /// default. Null means "whatever the loader resolves", which is today. String? _vDay; + /// The day list from the last read that worked. Kept when a picked day's + /// readings are dropped, so a day that keeps failing can still be stepped + /// off. + List? _vDays; + Future _loadVitals({bool force = false}) async { final repo = repoOf(context); if (repo == null || (_v != null && !force)) return; @@ -587,7 +593,9 @@ class _HealthScreenState extends State with RevisionReload { final t = beginRead(#vitals); try { final v = await VitalsData.load(repo, want: _vDay); - if (stillNewest(#vitals, t)) setState(() => (_v = v, _vFailed = false)); + if (stillNewest(#vitals, t)) { + setState(() => (_v = v, _vDays = v.days, _vFailed = false)); + } } catch (_) { if (stillNewest(#vitals, t)) setState(() => _vFailed = true); } @@ -1119,15 +1127,21 @@ class _HealthScreenState extends State with RevisionReload { final l = AppLocalizations.of(c); final v = _v; if (v == null) { - return _vFailed - ? _readFailed(l?.healthWhatVitals ?? 'vitals', () { - setState(() => _vFailed = false); - _loadVitals(); - }) - : const Padding( - padding: EdgeInsets.only(top: S.x8), - child: Center(child: CircularProgressIndicator()), - ); + final days = _vDays; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Null `_vDay` is today, the newest day in the list. + if (days != null && days.isNotEmpty) + ...dayNavRow(_vDay ?? days.first, days, _goVitalsDay), + _vFailed + ? _readFailed(l?.healthWhatVitals ?? 'vitals', () { + setState(() => _vFailed = false); + _loadVitals(); + }) + : const Padding( + padding: EdgeInsets.only(top: S.x8), + child: Center(child: CircularProgressIndicator()), + ), + ]); } // WHICH DAY this tab is showing. Every row here used to say "Today" for a diff --git a/test/vitals_day_failure_nav_test.dart b/test/vitals_day_failure_nav_test.dart new file mode 100644 index 000000000..357340337 --- /dev/null +++ b/test/vitals_day_failure_nav_test.dart @@ -0,0 +1,66 @@ +// Picking a Vitals day drops the old day's readings. A day whose read keeps +// failing must still leave the day stepper on screen, or there is no way off +// it. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +const _days = ['2026-05-22', '2026-05-20', '2026-05-19']; + +class _FailingDayRepo extends LocalRepository { + @override + Future> getToday() async => { + 'status': {'today_day': '2026-05-22'}, + }; + + @override + Future> availableDays() async => _days; + + @override + Future> getDayTimeline(String date) async => + throw const FormatException('database is locked'); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a picked day that keeps failing keeps the day stepper', + (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = _FailingDayRepo(); + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold( + body: HealthScreen( + data: HealthData(daysWithData: 3), + vitals: VitalsData(day: '2026-05-22', days: _days), + tab: 3, + ), + ), + ), + )); + await t.pump(); + expect(find.byType(DayNav), findsOneWidget); + + t.widget(find.byType(DayNav)).onDay('2026-05-19'); + await t.pump(); + await t.pump(const Duration(milliseconds: 50)); + + expect(find.textContaining('Could not read'), findsOneWidget); + expect(find.byType(DayNav), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-19'); + }); +} From 9bdd26a25d0a52d476b27145a7cb689afe0bb734 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 11:31:30 +0530 Subject: [PATCH 71/72] keep the sleep day stepper when a retry fails again --- lib/ui2/screens/sleep_detail.dart | 12 +++++++++--- test/ui2_sleep_detail_load_failure_test.dart | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 5dcd71e3d..1dc514654 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -499,6 +499,10 @@ class _SleepDetailState extends State with RevisionReload { } SleepData? _d; + + /// The last read that worked. A retry drops `_d` so the old night is not + /// drawn while it runs; the error view still steps through this one's days. + SleepData? _lastGood; bool _loading = true; String? _day; @@ -627,6 +631,7 @@ class _SleepDetailState extends State with RevisionReload { if (mounted && gen == _loadGen) { setState(() => ( _d = d, + _lastGood = d, _rough = rough, _correctable = correctable, _wholeNightKept = wholeNightKept, @@ -646,7 +651,7 @@ class _SleepDetailState extends State with RevisionReload { /// it goes with it. void _goDay(String day) { setState(() { - _day = sleepStepperDay(day, _d?.latestDay); + _day = sleepStepperDay(day, (_d ?? _lastGood)?.latestDay); _scrub = null; _overrideFailed = null; _loading = true; @@ -673,9 +678,10 @@ class _SleepDetailState extends State with RevisionReload { // day list is used, so the stepper stays and the day can be walked off. // A step to the latest night stores null in `_day`, so that night is // named from `latestDay`, not from the night stepped away from. - final shown = _day ?? d.latestDay ?? d.day; + final nav = _d ?? _lastGood ?? d; + final shown = _day ?? nav.latestDay ?? nav.day; return _frame(c, title, [ - ...dayNavRow(shown, d.days, _goDay), + ...dayNavRow(shown, nav.days, _goDay), const SizedBox(height: S.x2), StatusCard( shown == null diff --git a/test/ui2_sleep_detail_load_failure_test.dart b/test/ui2_sleep_detail_load_failure_test.dart index 375603bfd..3e8dea385 100644 --- a/test/ui2_sleep_detail_load_failure_test.dart +++ b/test/ui2_sleep_detail_load_failure_test.dart @@ -159,6 +159,25 @@ void main() { await _settle(t); expect(find.text('No night to show'), findsOneWidget); }, timeout: const Timeout(Duration(seconds: 60))); + + testWidgets('a retry that fails again keeps the day stepper', (t) async { + final repo = _FlakyRepo(0); + await _pump(t, repo, const SleepDetail(day: '2026-05-19')); + + repo.failures = 1; + t.widget(find.byType(DayNav)).onDay('2026-05-20'); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsOneWidget); + + repo.failures = 1; + await t.tap(find.text('Try again')); + await t.pump(); + await _settle(t); + expect(find.textContaining('could not be read'), findsOneWidget); + expect(find.byType(DayNav), findsOneWidget); + expect(t.widget(find.byType(DayNav)).day, '2026-05-20'); + }, timeout: const Timeout(Duration(seconds: 60))); }); group('the day timeline', () { From d338370a5675523f4b8f02a21384017195ecc53c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 11:32:12 +0530 Subject: [PATCH 72/72] only null a profile field in setup that opened filled and was cleared a field that opened blank writes nothing, so a weight a scale added while setup was open isn't overwritten. --- lib/ui2/onboarding/profile_setup.dart | 26 ++++++++++++++++---------- test/ui2_router_test.dart | 13 +++++++------ 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/ui2/onboarding/profile_setup.dart b/lib/ui2/onboarding/profile_setup.dart index 087fb0d3a..e8f16fb12 100644 --- a/lib/ui2/onboarding/profile_setup.dart +++ b/lib/ui2/onboarding/profile_setup.dart @@ -125,17 +125,23 @@ class _ProfileSetupViewState extends State { super.dispose(); } - /// Only what was actually entered. A blank field writes an explicit null, - /// not nothing: the fields open filled from the stored profile, and the save - /// merges into it, so leaving a cleared one out kept the old value feeding - /// calories and load. With null the dependent metric abstains. The fields + /// Only what was actually entered. A field that opened filled and was + /// cleared writes an explicit null: the save merges into the stored + /// profile, so leaving it out kept the old value feeding calories and load. + /// A field that opened blank and stayed blank writes nothing, so a value + /// that arrived meanwhile (a scale weighing in) is not nulled. The fields /// are typed in the units on their labels and stored in metric. - Map _fields() => { - if (_sex != null) 'sex': _sex, - 'age': Typed.of(_age.text).value?.round(), - 'height_cm': _heightCm(), - 'weight_kg': _weightKg(), - }; + Map _fields() { + final out = {if (_sex != null) 'sex': _sex}; + void put(String key, Object? v) { + if (v != null || widget.initial[key] != null) out[key] = v; + } + + put('age', Typed.of(_age.text).value?.round()); + put('height_cm', _heightCm()); + put('weight_kg', _weightKg()); + return out; + } /// Continue, unless something typed cannot be read — a typo is not a blank, /// and dropping it silently is how a body ends up half-described. diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 4798b04fc..07b5db88c 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -322,11 +322,12 @@ void main() { expect(saved, isNotNull); expect(saved!['sex'], 'f'); - // The three optional fields were left blank, so they are null — not - // zero, and not a default body. - expect(saved!['age'], isNull); - expect(saved!['height_cm'], isNull); - expect(saved!['weight_kg'], isNull); + // The three optional fields opened blank and stayed blank, so they are + // ABSENT — not zero, not a default body, and not a null over a value + // that arrived while setup was open. + expect(saved!.containsKey('age'), isFalse); + expect(saved!.containsKey('height_cm'), isFalse); + expect(saved!.containsKey('weight_kg'), isFalse); }); testWidgets('what is typed is what is written', (tester) async { @@ -346,7 +347,7 @@ void main() { expect(saved!['sex'], 'm'); expect(saved!['age'], 34); expect(saved!['weight_kg'], 78.4); - expect(saved!['height_cm'], isNull); + expect(saved!.containsKey('height_cm'), isFalse); }); testWidgets('clearing a stored value writes null over it', (tester) async {