From 99893457b1dd559eb38a225bd3eba22a30b01066 Mon Sep 17 00:00:00 2001 From: therealslurp <118546910+therealslurp@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:39:26 -0500 Subject: [PATCH 1/6] Home: open on today when the app comes back from the background Home lives on in the tab stack, so a day stepped back onto survived the app being backgrounded, overnight too. Worse, "Back to today" and stepping forward onto today stored today's DATE, which does not move at midnight: Saturday morning opened on Friday's rings with "Back to today" under them, while every detail screen behind it opened on Saturday. Today is now stored as "follow today" (null), and an AppLifecycleListener onRestart (back from hidden, not Control Center) resets Home to today and reloads, which also picks up a day that rolled over in the background. --- lib/ui2/screens/home_screen.dart | 33 +++++++++++- test/ui2_home_day_switcher_test.dart | 79 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index c8b11cb35..a6ae3002d 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1525,8 +1525,17 @@ class _HomeScreenState extends State with RevisionReload { /// The day requested by the switcher, or null for "today" — the same /// null-means-today convention [_load] and [HomeData.load] already used /// before there was a switcher. + /// + /// NEVER today's own label: [_goDay] stores today as null. A dated "today" + /// does not move at midnight, so a tap on "Back to today" on Friday evening + /// opened Saturday morning on Friday's rings with "Back to today" under + /// them. String? _day; + /// Brings Home back to today whenever the app returns from the background. + /// See [_onReturnFromBackground]. + AppLifecycleListener? _lifecycle; + /// `availableDays()` — newest first — for [DayNav]. Refetched with every /// load: cheap, and a switcher stepping onto a day that just finished /// deriving must see it show up without a relaunch. @@ -1558,9 +1567,29 @@ class _HomeScreenState extends State with RevisionReload { @override void dispose() { _syncTapTimer?.cancel(); + _lifecycle?.dispose(); super.dispose(); } + /// OPENING THE APP MEANS TODAY. Home is kept alive in the tab stack, so a + /// day stepped back onto used to survive the app going to the background — + /// overnight, too — and the next morning opened on that old day while every + /// detail screen behind it opened on today. `onRestart` fires only on the + /// way back from hidden (backgrounded), not for Control Center or a + /// permission sheet, which leave the app merely inactive. + /// + /// It also reloads when the switcher was already on today: a day that rolled + /// over while the app was backgrounded moves no database revision, so + /// nothing else would re-read it. + void _onReturnFromBackground() { + if (!mounted) return; + setState(() { + _day = null; + if (_d?.dayId != todayLabel(widget.now)) _loading = true; + }); + _load(); + } + @override void initState() { super.initState(); @@ -1569,6 +1598,7 @@ class _HomeScreenState extends State with RevisionReload { _loading = false; return; } + _lifecycle = AppLifecycleListener(onRestart: _onReturnFromBackground); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } @@ -1611,7 +1641,8 @@ 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; + // Today is "follow today", not a date — see [_day]. + _day = day == todayLabel(widget.now) ? null : day; _loading = true; }); _load(); diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 019f503fd..bf3bae4a7 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -68,6 +68,14 @@ Future _settle(WidgetTester t, {int n = 40}) async { } } +/// Walk the app through [states] one transition at a time, the way the +/// engine reports them. +void _lifecycle(WidgetTester t, List states) { + for (final st in states) { + t.binding.handleAppLifecycleStateChanged(st); + } +} + void main() { testWidgets('stepping to the previous day loads and shows ITS numbers', (t) async { @@ -106,6 +114,77 @@ void main() { expect(find.text('70'), findsOneWidget); }); + testWidgets('coming back from the background opens on today', (t) async { + // Home lives on in the tab stack, so a day stepped back onto used to + // survive a night in the background: Saturday morning opened on Friday's + // rings with "Back to today" under them. + 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: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + expect(find.text('42'), findsOneWidget); + + // A glance at Control Center only makes the app inactive: stay put. + _lifecycle(t, const [AppLifecycleState.inactive, AppLifecycleState.resumed]); + await _settle(t); + expect(find.text('42'), findsOneWidget); + + // Backgrounded and reopened: today, and no "Back to today". + _lifecycle(t, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); + await _settle(t); + expect(find.text('70'), findsOneWidget); + expect(find.text('42'), findsNothing); + expect(find.text('Back to today'), findsNothing); + }); + + testWidgets('"Back to today" follows today instead of pinning a date', + (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); + await t.tap(find.text('Back to today')); + await _settle(t); + expect(find.text('70'), findsOneWidget); + // Stored as "today" (null), which is what the day ring opens on — a + // pinned date would hand the detail screen today's label instead. + t.widget(find.byType(RingTrio)).onOpen!(HomeRingKind.sleep); + await _settle(t); + expect(t.widget(find.byType(SleepDetail)).day, isNull); + }); + testWidgets('on a past day the day breakdown opens THAT day', (t) async { final app = AppState.forTesting(); addTearDown(app.dispose); From 31815539005df06e519e4787d2fac295db317244 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 00:33:06 +0530 Subject: [PATCH 2/6] drop the past day before reloading home on resume the old day's rings stayed on screen under today's header while the read ran, and for good if it failed, since the error card only shows with no day loaded. --- lib/ui2/screens/home_screen.dart | 9 +++++- test/ui2_home_day_switcher_test.dart | 46 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index a6ae3002d..e29f87e38 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1581,11 +1581,18 @@ class _HomeScreenState extends State with RevisionReload { /// It also reloads when the switcher was already on today: a day that rolled /// over while the app was backgrounded moves no database revision, so /// nothing else would re-read it. + /// + /// A past day on screen is dropped before the read, not after it: kept, its + /// rings would sit under today's header while the read runs, and stay there + /// for good if the read failed, since the error card only shows with no day. void _onReturnFromBackground() { if (!mounted) return; setState(() { _day = null; - if (_d?.dayId != todayLabel(widget.now)) _loading = true; + if (_d?.dayId != todayLabel(widget.now)) { + _d = null; + _loading = true; + } }); _load(); } diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index bf3bae4a7..50dd6c8ab 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -154,6 +154,42 @@ void main() { expect(find.text('Back to today'), findsNothing); }); + testWidgets('a failed read on the way back drops the past day', (t) async { + // The past day's rings must not stay up under today's header while the + // read runs, nor for good when it fails. + final app = AppState.forTesting(); + addTearDown(app.dispose); + final repo = _FlakyTodayRepo(); + app.repo = repo; + + 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); + expect(find.text('42'), findsOneWidget); + + repo.failToday = true; + _lifecycle(t, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); + await t.pump(); + expect(find.text('42'), findsNothing); + await _settle(t); + expect(find.text('42'), findsNothing); + expect(find.text('Today could not be read'), findsOneWidget); + }); + testWidgets('"Back to today" follows today instead of pinning a date', (t) async { final app = AppState.forTesting(); @@ -320,3 +356,13 @@ class _FailingRepo extends _EmptyPastRepo { @override Future> availableDays() async => throw StateError('read failed'); } + +/// [_Repo] whose today read can be made to throw. +class _FlakyTodayRepo extends _Repo { + bool failToday = false; + @override + Future> getToday() async { + if (failToday) throw StateError('read failed'); + return super.getToday(); + } +} From 1d188de1dc9dd7d99f449e5997b49d6ae1fe9bcf Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 1 Nov 2026 14:51:55 +0530 Subject: [PATCH 3/6] keep a past day on Home after a quick trip to another app coming back from the background used to drop whatever day was on screen, so glancing at a message while looking at last Tuesday threw the place away. Home now goes back to today only after 30 minutes away, or when the date changed while it was in the background. --- lib/ui2/screens/home_screen.dart | 54 +++++++++++++++-- test/ui2_home_day_switcher_test.dart | 88 +++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 35b33bf0f..0c0ad4fd0 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1557,6 +1557,27 @@ class HomeScreen extends StatefulWidget { String? homeSwitcherDay(String day, {DateTime? now}) => day == todayLabel(now) ? null : day; +/// How long the app has to be away before Home gives up the day it was on. +/// A quick switch to another app and back keeps the place; a longer absence +/// means the past day is no longer what the user came back for. +const kHomeKeepDayAway = Duration(minutes: 30); + +/// Whether Home drops a past day for today on the way back from the +/// background: after [kHomeKeepDayAway], or when the calendar day changed +/// while away (a day stepped back onto on Friday evening is not what Saturday +/// morning opens on). [dayAtHide] null means the leave was never seen, so +/// the absence is unknown and today wins. +bool homeReturnsToToday({ + required String? dayAtHide, + required String dayNow, + required Duration away, +}) => + dayAtHide == null || dayAtHide != dayNow || away >= kHomeKeepDayAway; + +/// Test seam: stands in for the time Home measured the app as away. +@visibleForTesting +Duration? debugHomeAwayOverride; + class _HomeScreenState extends State with RevisionReload { HomeData? _d; bool _loading = true; @@ -1571,10 +1592,16 @@ class _HomeScreenState extends State with RevisionReload { /// them. String? _day; - /// Brings Home back to today whenever the app returns from the background. + /// Brings Home back to today when the app returns from the background. /// See [_onReturnFromBackground]. AppLifecycleListener? _lifecycle; + /// Today's label and a running stopwatch, both taken as the app went to + /// the background. The stopwatch is monotonic, so a clock change while + /// away can't stretch or shrink the absence; the label catches midnight. + String? _dayAtHide; + final Stopwatch _away = Stopwatch(); + /// `availableDays()` — newest first — for [DayNav]. Refetched with every /// load: cheap, and a switcher stepping onto a day that just finished /// deriving must see it show up without a relaunch. @@ -1610,11 +1637,19 @@ class _HomeScreenState extends State with RevisionReload { super.dispose(); } - /// OPENING THE APP MEANS TODAY. Home is kept alive in the tab stack, so a + void _onHide() { + _dayAtHide = todayLabel(widget.now); + _away + ..reset() + ..start(); + } + + /// COMING BACK LATER MEANS TODAY. Home is kept alive in the tab stack, so a /// day stepped back onto used to survive the app going to the background — /// overnight, too — and the next morning opened on that old day while every - /// detail screen behind it opened on today. `onRestart` fires only on the - /// way back from hidden (backgrounded), not for Control Center or a + /// detail screen behind it opened on today. A quick trip to another app and + /// back keeps the day (see [homeReturnsToToday]). `onRestart` fires only on + /// the way back from hidden (backgrounded), not for Control Center or a /// permission sheet, which leave the app merely inactive. /// /// It also reloads when the switcher was already on today: a day that rolled @@ -1626,6 +1661,14 @@ class _HomeScreenState extends State with RevisionReload { /// for good if the read failed, since the error card only shows with no day. void _onReturnFromBackground() { if (!mounted) return; + final away = debugHomeAwayOverride ?? _away.elapsed; + _away.stop(); + final back = homeReturnsToToday( + dayAtHide: _dayAtHide, dayNow: todayLabel(widget.now), away: away); + _dayAtHide = null; + // A past day kept after a short absence needs no read here: if its data + // changes, the database revision reloads it like any other day. + if (_day != null && !back) return; setState(() { _day = null; if (_d?.dayId != todayLabel(widget.now)) { @@ -1644,7 +1687,8 @@ class _HomeScreenState extends State with RevisionReload { _loading = false; return; } - _lifecycle = AppLifecycleListener(onRestart: _onReturnFromBackground); + _lifecycle = AppLifecycleListener( + onHide: _onHide, onRestart: _onReturnFromBackground); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 810a110f4..1a7270366 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -115,10 +115,94 @@ void main() { expect(find.text('70'), findsOneWidget); }); - testWidgets('coming back from the background opens on today', (t) async { + const background = [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]; + + test('a past day is kept only for a short absence on the same day', () { + const day = '2026-10-31'; + expect( + homeReturnsToToday( + dayAtHide: day, dayNow: day, away: const Duration(minutes: 5)), + isFalse); + expect( + homeReturnsToToday( + dayAtHide: day, dayNow: day, away: const Duration(minutes: 29)), + isFalse); + expect( + homeReturnsToToday(dayAtHide: day, dayNow: day, away: kHomeKeepDayAway), + isTrue); + // Midnight while away: today, however short the trip. + expect( + homeReturnsToToday( + dayAtHide: day, + dayNow: '2026-11-01', + away: const Duration(minutes: 2)), + isTrue); + // The leave was never seen: the absence is unknown. + expect(homeReturnsToToday(dayAtHide: null, dayNow: day, away: Duration.zero), + isTrue); + }); + + testWidgets('a quick trip to another app keeps the past day', (t) async { + debugHomeAwayOverride = const Duration(minutes: 2); + addTearDown(() => debugHomeAwayOverride = null); + 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: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + expect(find.text('42'), findsOneWidget); + + _lifecycle(t, background); + await _settle(t); + expect(find.text('42'), findsOneWidget); + expect(find.text('Back to today'), findsOneWidget); + }); + + testWidgets('following today stays on today after any absence', (t) async { + debugHomeAwayOverride = const Duration(minutes: 2); + addTearDown(() => debugHomeAwayOverride = null); + 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: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + expect(find.text('70'), findsOneWidget); + + _lifecycle(t, background); + await _settle(t); + expect(find.text('70'), findsOneWidget); + expect(find.text('Back to today'), findsNothing); + }); + + testWidgets('coming back after a long absence opens on today', (t) async { // Home lives on in the tab stack, so a day stepped back onto used to // survive a night in the background: Saturday morning opened on Friday's // rings with "Back to today" under them. + debugHomeAwayOverride = kHomeKeepDayAway; + addTearDown(() => debugHomeAwayOverride = null); final app = AppState.forTesting(); addTearDown(app.dispose); app.repo = _Repo(); @@ -176,6 +260,8 @@ void main() { expect(find.text('42'), findsOneWidget); repo.failToday = true; + debugHomeAwayOverride = const Duration(hours: 8); + addTearDown(() => debugHomeAwayOverride = null); _lifecycle(t, const [ AppLifecycleState.inactive, AppLifecycleState.hidden, From d0d0e529e6b992ffc81ba45d906d3554bce735ce Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 1 Nov 2026 15:03:41 +0530 Subject: [PATCH 4/6] count a locked phone's time as away on Home the stopwatch stops while the phone sleeps, so an hour with the screen locked could read as a minute and keep the past day. the absence is now the longer of the wall clock and the stopwatch. --- lib/ui2/screens/home_screen.dart | 21 +++++++++++++++++---- test/ui2_home_day_switcher_test.dart | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 0c0ad4fd0..23a16fc69 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1574,6 +1574,12 @@ bool homeReturnsToToday({ }) => dayAtHide == null || dayAtHide != dayNow || away >= kHomeKeepDayAway; +/// How long the app was away, from both clocks Home keeps: the longer one. +/// The [monotonic] stopwatch stops while the phone sleeps, so a locked +/// phone reads short on it; the [wall] clock can be set back while away. +Duration homeAwayFor({required Duration wall, required Duration monotonic}) => + wall > monotonic ? wall : monotonic; + /// Test seam: stands in for the time Home measured the app as away. @visibleForTesting Duration? debugHomeAwayOverride; @@ -1596,10 +1602,11 @@ class _HomeScreenState extends State with RevisionReload { /// See [_onReturnFromBackground]. AppLifecycleListener? _lifecycle; - /// Today's label and a running stopwatch, both taken as the app went to - /// the background. The stopwatch is monotonic, so a clock change while - /// away can't stretch or shrink the absence; the label catches midnight. + /// Today's label, the wall time and a running stopwatch, all taken as the + /// app went to the background (see [homeAwayFor]). The label catches + /// midnight. String? _dayAtHide; + DateTime? _hiddenAt; final Stopwatch _away = Stopwatch(); /// `availableDays()` — newest first — for [DayNav]. Refetched with every @@ -1639,6 +1646,7 @@ class _HomeScreenState extends State with RevisionReload { void _onHide() { _dayAtHide = todayLabel(widget.now); + _hiddenAt = DateTime.now(); _away ..reset() ..start(); @@ -1661,8 +1669,13 @@ class _HomeScreenState extends State with RevisionReload { /// for good if the read failed, since the error card only shows with no day. void _onReturnFromBackground() { if (!mounted) return; - final away = debugHomeAwayOverride ?? _away.elapsed; + final hiddenAt = _hiddenAt; + final wall = + hiddenAt == null ? Duration.zero : DateTime.now().difference(hiddenAt); + final away = debugHomeAwayOverride ?? + homeAwayFor(wall: wall, monotonic: _away.elapsed); _away.stop(); + _hiddenAt = null; final back = homeReturnsToToday( dayAtHide: _dayAtHide, dayNow: todayLabel(widget.now), away: away); _dayAtHide = null; diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 1a7270366..ce6232829 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -149,6 +149,22 @@ void main() { isTrue); }); + test('a phone asleep while away still counts as away', () { + // The stopwatch stops while the phone sleeps: an hour locked reads as a + // minute on it, and only the wall clock sees the hour. + expect( + homeAwayFor( + wall: const Duration(hours: 1), + monotonic: const Duration(minutes: 1)), + const Duration(hours: 1)); + // The wall clock set back while away: the stopwatch still has it. + expect( + homeAwayFor( + wall: const Duration(minutes: -50), + monotonic: const Duration(minutes: 40)), + const Duration(minutes: 40)); + }); + testWidgets('a quick trip to another app keeps the past day', (t) async { debugHomeAwayOverride = const Duration(minutes: 2); addTearDown(() => debugHomeAwayOverride = null); From 2487125d62ad71b813d493297d1d46e2feb0517f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 1 Nov 2026 15:53:48 +0530 Subject: [PATCH 5/6] test Home's absence through the real hide and restart path the lifecycle tests used to hand Home the absence directly, so the wall clock reading was never exercised. they now move an injected clock between going to the background and coming back. --- lib/ui2/screens/home_screen.dart | 14 +++--- test/ui2_home_day_switcher_test.dart | 70 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 23a16fc69..9e38d88ed 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1580,9 +1580,9 @@ bool homeReturnsToToday({ Duration homeAwayFor({required Duration wall, required Duration monotonic}) => wall > monotonic ? wall : monotonic; -/// Test seam: stands in for the time Home measured the app as away. +/// The wall clock Home reads for an absence; tests move it. @visibleForTesting -Duration? debugHomeAwayOverride; +DateTime Function() debugHomeWallClock = DateTime.now; class _HomeScreenState extends State with RevisionReload { HomeData? _d; @@ -1646,7 +1646,7 @@ class _HomeScreenState extends State with RevisionReload { void _onHide() { _dayAtHide = todayLabel(widget.now); - _hiddenAt = DateTime.now(); + _hiddenAt = debugHomeWallClock(); _away ..reset() ..start(); @@ -1670,10 +1670,10 @@ class _HomeScreenState extends State with RevisionReload { void _onReturnFromBackground() { if (!mounted) return; final hiddenAt = _hiddenAt; - final wall = - hiddenAt == null ? Duration.zero : DateTime.now().difference(hiddenAt); - final away = debugHomeAwayOverride ?? - homeAwayFor(wall: wall, monotonic: _away.elapsed); + final wall = hiddenAt == null + ? Duration.zero + : debugHomeWallClock().difference(hiddenAt); + final away = homeAwayFor(wall: wall, monotonic: _away.elapsed); _away.stop(); _hiddenAt = null; final back = homeReturnsToToday( diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index ce6232829..5afcbc1fd 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -77,6 +77,31 @@ void _lifecycle(WidgetTester t, List states) { } } +/// The wall clock Home reads, frozen until a test moves it. +class _Clock { + DateTime now = DateTime(2026, 10, 31, 12); + _Clock() { + debugHomeWallClock = () => now; + addTearDown(() => debugHomeWallClock = DateTime.now); + } +} + +/// Send the app to the background, let [away] pass on [clock], and bring it +/// back, through the transitions the engine reports. +void _background(WidgetTester t, _Clock clock, Duration away) { + _lifecycle(t, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]); + clock.now = clock.now.add(away); + _lifecycle(t, const [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); +} + void main() { testWidgets('stepping to the previous day loads and shows ITS numbers', (t) async { @@ -115,15 +140,6 @@ void main() { expect(find.text('70'), findsOneWidget); }); - const background = [ - AppLifecycleState.inactive, - AppLifecycleState.hidden, - AppLifecycleState.paused, - AppLifecycleState.hidden, - AppLifecycleState.inactive, - AppLifecycleState.resumed, - ]; - test('a past day is kept only for a short absence on the same day', () { const day = '2026-10-31'; expect( @@ -166,8 +182,7 @@ void main() { }); testWidgets('a quick trip to another app keeps the past day', (t) async { - debugHomeAwayOverride = const Duration(minutes: 2); - addTearDown(() => debugHomeAwayOverride = null); + final clock = _Clock(); final app = AppState.forTesting(); addTearDown(app.dispose); app.repo = _Repo(); @@ -184,15 +199,14 @@ void main() { await _settle(t); expect(find.text('42'), findsOneWidget); - _lifecycle(t, background); + _background(t, clock, const Duration(minutes: 2)); await _settle(t); expect(find.text('42'), findsOneWidget); expect(find.text('Back to today'), findsOneWidget); }); testWidgets('following today stays on today after any absence', (t) async { - debugHomeAwayOverride = const Duration(minutes: 2); - addTearDown(() => debugHomeAwayOverride = null); + final clock = _Clock(); final app = AppState.forTesting(); addTearDown(app.dispose); app.repo = _Repo(); @@ -207,7 +221,7 @@ void main() { await _settle(t); expect(find.text('70'), findsOneWidget); - _lifecycle(t, background); + _background(t, clock, const Duration(minutes: 2)); await _settle(t); expect(find.text('70'), findsOneWidget); expect(find.text('Back to today'), findsNothing); @@ -217,8 +231,7 @@ void main() { // Home lives on in the tab stack, so a day stepped back onto used to // survive a night in the background: Saturday morning opened on Friday's // rings with "Back to today" under them. - debugHomeAwayOverride = kHomeKeepDayAway; - addTearDown(() => debugHomeAwayOverride = null); + final clock = _Clock(); final app = AppState.forTesting(); addTearDown(app.dispose); app.repo = _Repo(); @@ -240,15 +253,8 @@ void main() { await _settle(t); expect(find.text('42'), findsOneWidget); - // Backgrounded and reopened: today, and no "Back to today". - _lifecycle(t, const [ - AppLifecycleState.inactive, - AppLifecycleState.hidden, - AppLifecycleState.paused, - AppLifecycleState.hidden, - AppLifecycleState.inactive, - AppLifecycleState.resumed, - ]); + // Away for half an hour: today, and no "Back to today". + _background(t, clock, kHomeKeepDayAway); await _settle(t); expect(find.text('70'), findsOneWidget); expect(find.text('42'), findsNothing); @@ -276,16 +282,8 @@ void main() { expect(find.text('42'), findsOneWidget); repo.failToday = true; - debugHomeAwayOverride = const Duration(hours: 8); - addTearDown(() => debugHomeAwayOverride = null); - _lifecycle(t, const [ - AppLifecycleState.inactive, - AppLifecycleState.hidden, - AppLifecycleState.paused, - AppLifecycleState.hidden, - AppLifecycleState.inactive, - AppLifecycleState.resumed, - ]); + final clock = _Clock(); + _background(t, clock, const Duration(hours: 8)); await t.pump(); expect(find.text('42'), findsNothing); await _settle(t); From 0b1e4b58bc1c634939856e197f1b4f325ae606fa Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 1 Nov 2026 17:55:35 +0530 Subject: [PATCH 6/6] test Home's midnight reset through the hide and restart path the day Home compares across an absence now comes from the same wall clock as the absence itself, so a test can cross midnight on a short trip. --- lib/ui2/screens/home_screen.dart | 6 ++++-- test/ui2_home_day_switcher_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 9e38d88ed..20a13feff 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1645,7 +1645,7 @@ class _HomeScreenState extends State with RevisionReload { } void _onHide() { - _dayAtHide = todayLabel(widget.now); + _dayAtHide = todayLabel(widget.now ?? debugHomeWallClock()); _hiddenAt = debugHomeWallClock(); _away ..reset() @@ -1677,7 +1677,9 @@ class _HomeScreenState extends State with RevisionReload { _away.stop(); _hiddenAt = null; final back = homeReturnsToToday( - dayAtHide: _dayAtHide, dayNow: todayLabel(widget.now), away: away); + dayAtHide: _dayAtHide, + dayNow: todayLabel(widget.now ?? debugHomeWallClock()), + away: away); _dayAtHide = null; // A past day kept after a short absence needs no read here: if its data // changes, the database revision reloads it like any other day. diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 5afcbc1fd..e735ff28a 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -205,6 +205,31 @@ void main() { expect(find.text('Back to today'), findsOneWidget); }); + testWidgets('midnight while away opens on today, however short the trip', + (t) async { + final clock = _Clock()..now = DateTime(2026, 10, 31, 23, 59); + 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: HomeScreen(hour: 9)), + ), + )); + await _settle(t); + await t.tap(find.bySemanticsLabel('Previous day')); + await _settle(t); + expect(find.text('42'), findsOneWidget); + + _background(t, clock, const Duration(minutes: 2)); + await _settle(t); + expect(find.text('70'), findsOneWidget); + expect(find.text('Back to today'), findsNothing); + }); + testWidgets('following today stays on today after any absence', (t) async { final clock = _Clock(); final app = AppState.forTesting();