diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index 66ffc5f1f..20a13feff 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -1557,6 +1557,33 @@ 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; + +/// 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; + +/// The wall clock Home reads for an absence; tests move it. +@visibleForTesting +DateTime Function() debugHomeWallClock = DateTime.now; + class _HomeScreenState extends State with RevisionReload { HomeData? _d; bool _loading = true; @@ -1564,8 +1591,24 @@ 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 when the app returns from the background. + /// See [_onReturnFromBackground]. + AppLifecycleListener? _lifecycle; + + /// 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 /// load: cheap, and a switcher stepping onto a day that just finished /// deriving must see it show up without a relaunch. @@ -1597,9 +1640,60 @@ class _HomeScreenState extends State with RevisionReload { @override void dispose() { _syncTapTimer?.cancel(); + _lifecycle?.dispose(); super.dispose(); } + void _onHide() { + _dayAtHide = todayLabel(widget.now ?? debugHomeWallClock()); + _hiddenAt = debugHomeWallClock(); + _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. 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 + /// 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; + final hiddenAt = _hiddenAt; + final wall = hiddenAt == null + ? Duration.zero + : debugHomeWallClock().difference(hiddenAt); + final away = homeAwayFor(wall: wall, monotonic: _away.elapsed); + _away.stop(); + _hiddenAt = null; + final back = homeReturnsToToday( + 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. + if (_day != null && !back) return; + setState(() { + _day = null; + if (_d?.dayId != todayLabel(widget.now)) { + _d = null; + _loading = true; + } + }); + _load(); + } + @override void initState() { super.initState(); @@ -1608,6 +1702,8 @@ class _HomeScreenState extends State with RevisionReload { _loading = false; return; } + _lifecycle = AppLifecycleListener( + onHide: _onHide, onRestart: _onReturnFromBackground); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } @@ -1650,7 +1746,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 = homeSwitcherDay(day); + _day = homeSwitcherDay(day, now: widget.now); // 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 diff --git a/test/ui2_home_day_switcher_test.dart b/test/ui2_home_day_switcher_test.dart index 57604d8c6..e735ff28a 100644 --- a/test/ui2_home_day_switcher_test.dart +++ b/test/ui2_home_day_switcher_test.dart @@ -69,6 +69,39 @@ 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); + } +} + +/// 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 { @@ -107,6 +140,213 @@ void main() { expect(find.text('70'), findsOneWidget); }); + 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); + }); + + 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 { + final clock = _Clock(); + 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('42'), findsOneWidget); + 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(); + 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); + + _background(t, clock, const Duration(minutes: 2)); + 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. + final clock = _Clock(); + 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); + + // 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); + 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; + final clock = _Clock(); + _background(t, clock, const Duration(hours: 8)); + 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(); + 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); @@ -419,3 +659,13 @@ class _PastDayThrowsRepo extends _Repo { Future> getDayOverview(String date) 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(); + } +}