From 79bc09f46e8a4e1aa674c69ec6c5c14a28c49a10 Mon Sep 17 00:00:00 2001 From: Human Date: Mon, 21 Sep 2026 09:31:57 -0700 Subject: [PATCH 1/3] Add a Home entry point to the AI briefing screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only quick way into AiBriefingScreen was the notification that fires when a briefing is ready — dismiss or miss it, and the screen was two non-obvious taps deep behind Coach's overflow menu instead, which itself only appears once AI/BYOK is configured. Added a plain navigation row (detailLinkRow, the same "door" pattern already used for "Breakdown of your day") under the existing plan-rows card in Today's plan. Shown unconditionally, not gated on AI being configured: AiBriefingScreen already has a graceful "no model set up" state with its own way to fix that, so gating the door here would just duplicate it. Subtitle shows today's cached one-liner when one exists, otherwise prompts to write one. Inherits the existing isToday gate on the whole section, since a cached briefing is only ever for today. Adds homeBriefingTitle / homeBriefingSubtitleEmpty to app_en.arb --- .../OpenStrapWatch Watch App.xcscheme | 2 - lib/l10n/app_en.arb | 8 +++ lib/ui2/screens/home_screen.dart | 62 ++++++++++++++----- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/OpenStrapWatch Watch App.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/OpenStrapWatch Watch App.xcscheme index bf9d64e7..6d7935ed 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/OpenStrapWatch Watch App.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/OpenStrapWatch Watch App.xcscheme @@ -47,7 +47,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "53962EBD2FF6EF790061A61B" BuildableName = "OpenStrapWatch Watch App.app" - BlueprintName = "OpenStrapWatch Watch App" ReferencedContainer = "container:Runner.xcodeproj"> @@ -64,7 +63,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "53962EBD2FF6EF790061A61B" BuildableName = "OpenStrapWatch Watch App.app" - BlueprintName = "OpenStrapWatch Watch App" ReferencedContainer = "container:Runner.xcodeproj"> diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d1c1cce9..8581c30e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7445,6 +7445,14 @@ "@homeBreakdownSubtitle": { "description": "Link row subtitle under the breakdown-of-your-day title." }, + "homeBriefingTitle": "Briefing", + "@homeBriefingTitle": { + "description": "Link row title under Today's plan: opens the AI briefing screen. Constant regardless of morning/evening period." + }, + "homeBriefingSubtitleEmpty": "Tap to write today's summary", + "@homeBriefingSubtitleEmpty": { + "description": "Link row subtitle under the briefing title when nothing has been generated for today yet." + }, "homeIllnessRedTitle": "Several nights in a row are away from your normal", "@homeIllnessRedTitle": { "description": "Illness watch headline when several nights in a row are away from baseline (red state)." diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 44a23c51..a8b38476 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -32,6 +32,8 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; +import '../../ai/briefing.dart' + show BriefingStore, currentBriefingPeriod; import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; import '../../data/db.dart' show DbRebuild; import '../../data/journal_fields.dart' show formatMinuteOfDay; @@ -46,6 +48,7 @@ import '../activity/day_strain.dart' show DayStrainDetail; import '../profile/devices.dart' show formatDayTime; import '../profile/profile.dart'; import '../ui2.dart'; +import 'ai_briefing.dart' show AiBriefingScreen; import 'coach.dart'; import 'day_timeline.dart' show DayTimelineScreen; import 'metric_detail.dart'; @@ -2049,24 +2052,49 @@ class _HomeScreenState extends State with RevisionReload { false)); } - if (rows.isEmpty) { - return StatusCard.forMetric(l?.homeNoPlanTitle ?? 'No plan for today yet', d.sleepNeedMin, - // "none are established yet" is the COLD-START reason, and it is - // a wrong answer when the baselines exist and are being withheld. - why: d.insightsStale != null - ? (l?.homeNoPlanWhyStale ?? 'The cross-day rollup they come from is being rebuilt.') - : (l?.homeNoPlanWhyNone ?? 'None are established yet.')) ?? - const SizedBox.shrink(); - } + final planBody = rows.isEmpty + ? StatusCard.forMetric(l?.homeNoPlanTitle ?? 'No plan for today yet', d.sleepNeedMin, + // "none are established yet" is the COLD-START reason, and it + // is a wrong answer when the baselines exist and are being + // withheld. + why: d.insightsStale != null + ? (l?.homeNoPlanWhyStale ?? 'The cross-day rollup they come from is being rebuilt.') + : (l?.homeNoPlanWhyNone ?? 'None are established yet.')) ?? + const SizedBox.shrink() + : Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4, vertical: S.x2), + child: Column(children: [ + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) Divider(color: p.line, height: 1), + rows[i], + ], + ]), + ); - return Surface( - pad: const EdgeInsets.symmetric(horizontal: S.x4, vertical: S.x2), - child: Column(children: [ - for (var i = 0; i < rows.length; i++) ...[ - if (i > 0) Divider(color: p.line, height: 1), - rows[i], - ], - ]), + return Column(children: [ + planBody, + const SizedBox(height: S.x3), + _briefingDoor(c, d), + ]); + } + + /// The only quick way into [AiBriefingScreen] used to be the notification + /// that fires when a briefing is ready — dismiss or miss it, and the + /// screen was two non-obvious taps deep behind Coach's overflow menu + /// instead (see EDGE-14). Shown unconditionally, not gated on AI/BYOK + /// being configured: the screen itself already has a graceful + /// "no model set up" state with its own way to fix that, so gating here + /// would just duplicate that door rather than simplify anything. + Widget _briefingDoor(BuildContext c, HomeData d) { + final l = AppLocalizations.of(c); + final period = currentBriefingPeriod(DateTime.now()); + final cached = BriefingStore.read(period, day: d.dayId); + return detailLinkRow( + c, + LucideIcons.sparkles, + l?.homeBriefingTitle ?? 'Briefing', + cached?.oneLiner ?? (l?.homeBriefingSubtitleEmpty ?? 'Tap to write today\'s summary'), + () => go(c, AiBriefingScreen(period: period)), ); } From 3bc40727f156e991346f50aa4f446bbfe78b026b Mon Sep 17 00:00:00 2001 From: Human Date: Mon, 21 Sep 2026 10:10:26 -0700 Subject: [PATCH 2/3] fix evening briefing fallback and stale row after writing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues flagged on PR #447: - currentBriefingPeriod documents that past 17:00 it returns `evening` even before the evening recap exists, "falling back to the cached morning one until [it] exists" — but the briefing row just opened whatever period that function returned, so after 5pm with nothing written yet it showed the generic empty prompt and opened a bare evening screen instead of the morning briefing already in cache (Sourcery). Extracted the fallback into a pure function, resolveBriefingToShow(), in ai/briefing.dart. - BriefingStore.write (called from BriefingEngine.generate) never bumps AppState.insightsRevision, so RevisionReload's automatic reload never fires for it. The row read BriefingStore synchronously at build time with no way to know a briefing was written during the visit, so returning from AiBriefingScreen after writing one left Home showing the stale/empty subtitle until an unrelated revision bump happened to refresh it (CodeRabbit). The row now pushes the route directly (go() discards the pushed route's Future), awaits it, and calls reload() if still mounted. Adds test/resolve_briefing_to_show_test.dart (4 cases: morning as-is, evening with its own recap, evening falling back to morning, evening with neither written yet). --- lib/ai/briefing.dart | 21 +++++++++ lib/ui2/screens/home_screen.dart | 23 ++++++++-- test/resolve_briefing_to_show_test.dart | 59 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 test/resolve_briefing_to_show_test.dart diff --git a/lib/ai/briefing.dart b/lib/ai/briefing.dart index 332fb5da..d8dbacdf 100644 --- a/lib/ai/briefing.dart +++ b/lib/ai/briefing.dart @@ -97,6 +97,27 @@ class Briefing { } } +/// Resolves which period's briefing an entry point meaning "today's +/// briefing" (Home's link row, a generic notification tap) should actually +/// show, per [currentBriefingPeriod]'s own documented fallback: past 17:00 it +/// returns [BriefingPeriod.evening] even when nothing has been written there +/// yet, "falling back to the cached morning one until [it] exists." +/// +/// [current] is [BriefingStore.read] for [period]; [morningFallback] is the +/// same for [BriefingPeriod.morning] — passed in rather than read here so +/// this stays a pure function, testable without touching SharedPreferences. +({BriefingPeriod period, Briefing? briefing}) resolveBriefingToShow( + BriefingPeriod period, + Briefing? current, + Briefing? morningFallback, +) { + if (current != null) return (period: period, briefing: current); + if (morningFallback != null) { + return (period: BriefingPeriod.morning, briefing: morningFallback); + } + return (period: period, briefing: null); +} + /// Per-day+period briefing cache + the journal "done for today" flag. class BriefingStore { BriefingStore._(); diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index a8b38476..3aa8cf50 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -33,7 +33,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ai/briefing.dart' - show BriefingStore, currentBriefingPeriod; + show BriefingPeriod, BriefingStore, currentBriefingPeriod, resolveBriefingToShow; import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; import '../../data/db.dart' show DbRebuild; import '../../data/journal_fields.dart' show formatMinuteOfDay; @@ -2088,13 +2088,30 @@ class _HomeScreenState extends State with RevisionReload { Widget _briefingDoor(BuildContext c, HomeData d) { final l = AppLocalizations.of(c); final period = currentBriefingPeriod(DateTime.now()); - final cached = BriefingStore.read(period, day: d.dayId); + final resolved = resolveBriefingToShow( + period, + BriefingStore.read(period, day: d.dayId), + BriefingStore.read(BriefingPeriod.morning, day: d.dayId), + ); + final foundPeriod = resolved.period; + final cached = resolved.briefing; return detailLinkRow( c, LucideIcons.sparkles, l?.homeBriefingTitle ?? 'Briefing', cached?.oneLiner ?? (l?.homeBriefingSubtitleEmpty ?? 'Tap to write today\'s summary'), - () => go(c, AiBriefingScreen(period: period)), + () async { + // Writing a briefing (BriefingStore.write, in briefing_engine.dart) + // does not bump AppState.insightsRevision, so RevisionReload's + // automatic reload never fires for it — awaiting the route and + // reloading on return is the only way this row picks up a briefing + // written during the visit instead of showing stale/empty text until + // some UNRELATED revision bump happens to refresh Home. + final screen = AiBriefingScreen(period: foundPeriod); + await Navigator.of(c).push( + themedRoute((_) => screen, name: screen.runtimeType.toString())); + if (mounted) reload(); + }, ); } diff --git a/test/resolve_briefing_to_show_test.dart b/test/resolve_briefing_to_show_test.dart new file mode 100644 index 00000000..0e8d77ce --- /dev/null +++ b/test/resolve_briefing_to_show_test.dart @@ -0,0 +1,59 @@ +// resolveBriefingToShow backs the Home "Briefing" link row (see EDGE-14 / +// PR #447). currentBriefingPeriod documents that past 17:00 it returns +// `evening` even before the evening recap exists, "falling back to the +// cached morning one until [it] exists" — a contract the row's first version +// did not honor: it just opened whatever currentBriefingPeriod said, so +// after 5pm with no evening sweep written yet it showed the generic "tap to +// write" prompt and opened an empty evening screen instead of the morning +// briefing already sitting in cache (Sourcery finding on PR #447). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ai/briefing.dart'; + +Briefing _briefing(BriefingPeriod period, String oneLiner) => Briefing( + day: '2026-09-20', + period: period, + oneLiner: oneLiner, + breakdownMd: '', + generatedAtMs: 0, + inputs: const {}, + ); + +void main() { + test('morning, before 17:00: the morning briefing is used as-is', () { + final morning = _briefing(BriefingPeriod.morning, 'slept well'); + final result = + resolveBriefingToShow(BriefingPeriod.morning, morning, morning); + expect(result.period, BriefingPeriod.morning); + expect(result.briefing, morning); + }); + + test('evening, recap already written: the evening briefing is used', () { + final morning = _briefing(BriefingPeriod.morning, 'slept well'); + final evening = _briefing(BriefingPeriod.evening, 'good day overall'); + final result = + resolveBriefingToShow(BriefingPeriod.evening, evening, morning); + expect(result.period, BriefingPeriod.evening); + expect(result.briefing, evening); + }); + + test( + 'evening, recap not written yet, morning cached: falls back to the ' + 'morning briefing', () { + final morning = _briefing(BriefingPeriod.morning, 'slept well'); + final result = + resolveBriefingToShow(BriefingPeriod.evening, null, morning); + expect(result.period, BriefingPeriod.morning, + reason: 'currentBriefingPeriod documents this exact fallback'); + expect(result.briefing, morning); + }); + + test('evening, neither written yet: stays on evening with nothing cached', + () { + final result = resolveBriefingToShow(BriefingPeriod.evening, null, null); + expect(result.period, BriefingPeriod.evening, + reason: 'the destination screen\'s own "write one now" state should ' + 'open for the period the user actually asked about'); + expect(result.briefing, isNull); + }); +} From 70831fc1d456e8abf83b2c91018db4e71977d88c Mon Sep 17 00:00:00 2001 From: Human Date: Mon, 21 Sep 2026 10:34:58 -0700 Subject: [PATCH 3/3] Fix stale briefing period captured by Home's IndexedStack-kept-alive tap closure CodeRabbit found a staleness gap on PR #447: the briefing door's onTap closure captured `foundPeriod` (and the cached briefing used for the subtitle) from whatever build last ran. Home is kept alive indefinitely by the shell's IndexedStack (app_shell.dart), so a build can sit for hours without rerunning. Long enough to span the 17:00 morning/evening boundary, or to miss a briefing written in the background after that build. Tapping the row in that window could navigate to the wrong period. Extracted the period+cache resolution into _resolveBriefingNow(), called once at build time for the subtitle and again fresh inside the onTap closure right before constructing AiBriefingScreen, so a tap always reflects the actual current time and the latest stored briefing rather than whatever was true when Home last rebuilt. --- lib/ui2/screens/home_screen.dart | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 3aa8cf50..dd6b1bbf 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -33,7 +33,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ai/briefing.dart' - show BriefingPeriod, BriefingStore, currentBriefingPeriod, resolveBriefingToShow; + show Briefing, BriefingPeriod, BriefingStore, currentBriefingPeriod, resolveBriefingToShow; import '../../data/day_label.dart' show todayLabel, calendarDaysBetween; import '../../data/db.dart' show DbRebuild; import '../../data/journal_fields.dart' show formatMinuteOfDay; @@ -2085,29 +2085,41 @@ class _HomeScreenState extends State with RevisionReload { /// being configured: the screen itself already has a graceful /// "no model set up" state with its own way to fix that, so gating here /// would just duplicate that door rather than simplify anything. - Widget _briefingDoor(BuildContext c, HomeData d) { - final l = AppLocalizations.of(c); + /// Re-run on every call rather than cached by the caller: Home is kept + /// alive by the shell's `IndexedStack` (see revision.dart), so a build can + /// sit for hours without rerunning. Resolving once at build time and + /// capturing the result in the row's `onTap` closure would let a stale + /// morning/evening decision — or a briefing written in the background + /// after that build — survive across the 17:00 boundary until Home + /// happens to rebuild for an unrelated reason. + ({BriefingPeriod period, Briefing? briefing}) _resolveBriefingNow(HomeData d) { final period = currentBriefingPeriod(DateTime.now()); - final resolved = resolveBriefingToShow( + return resolveBriefingToShow( period, BriefingStore.read(period, day: d.dayId), BriefingStore.read(BriefingPeriod.morning, day: d.dayId), ); - final foundPeriod = resolved.period; - final cached = resolved.briefing; + } + + Widget _briefingDoor(BuildContext c, HomeData d) { + final l = AppLocalizations.of(c); + final cached = _resolveBriefingNow(d).briefing; return detailLinkRow( c, LucideIcons.sparkles, l?.homeBriefingTitle ?? 'Briefing', cached?.oneLiner ?? (l?.homeBriefingSubtitleEmpty ?? 'Tap to write today\'s summary'), () async { + // Resolved fresh at tap time via _resolveBriefingNow, not read from + // the value above — see that method's doc for why. + // // Writing a briefing (BriefingStore.write, in briefing_engine.dart) // does not bump AppState.insightsRevision, so RevisionReload's // automatic reload never fires for it — awaiting the route and // reloading on return is the only way this row picks up a briefing // written during the visit instead of showing stale/empty text until // some UNRELATED revision bump happens to refresh Home. - final screen = AiBriefingScreen(period: foundPeriod); + final screen = AiBriefingScreen(period: _resolveBriefingNow(d).period); await Navigator.of(c).push( themedRoute((_) => screen, name: screen.runtimeType.toString())); if (mounted) reload();