From 6033e3815faf94dc4a37a06ede072b9e683a74bc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 22:06:43 +0100 Subject: [PATCH] Home: fit one screen, details behind tiles Stakes and history move behind two tiles that open bottom sheets; the page no longer scrolls at Pixel 6a geometry (home_fit_test). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWWcbzoov1KFZJSFcSs --- phone_app/lib/screens/home_body.dart | 161 +++++++++---------- phone_app/lib/screens/home_history_card.dart | 18 ++- phone_app/lib/screens/home_tiles.dart | 107 ++++++++++++ phone_app/lib/screens/stakes_card.dart | 12 +- phone_app/test/home_fit_test.dart | 120 ++++++++++++++ phone_app/test/home_history_card_test.dart | 9 ++ phone_app/test/stakes_card_test.dart | 17 +- 7 files changed, 357 insertions(+), 87 deletions(-) create mode 100644 phone_app/lib/screens/home_tiles.dart create mode 100644 phone_app/test/home_fit_test.dart diff --git a/phone_app/lib/screens/home_body.dart b/phone_app/lib/screens/home_body.dart index 7b7709e..ae97765 100644 --- a/phone_app/lib/screens/home_body.dart +++ b/phone_app/lib/screens/home_body.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:wake_alarm_sync/screens/home_history_card.dart'; import 'package:wake_alarm_sync/screens/home_presence_line.dart'; -import 'package:wake_alarm_sync/screens/stakes_card.dart'; +import 'package:wake_alarm_sync/screens/home_tiles.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// The home screen's body: the armed time, the earlier-only control and the @@ -56,90 +55,88 @@ class HomeBody extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Alarm is armed for', style: TextStyle(fontSize: 16)), - HomePresenceLine(key: ValueKey(syncEpoch)), - const SizedBox(height: 16), - Text( - time, - style: Theme.of( - context, - ).textTheme.displayLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - isOverridden - ? 'One-shot early start — back to $baseline ' - 'after it rings' - : 'Every day. It cannot be turned off.', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - ), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: busy ? null : onPickEarlier, - icon: const Icon(Icons.arrow_upward), - label: const Text('Wake me earlier tomorrow'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + // Fits one screen: stakes and history sit behind tiles. The scroll view + // is only the landscape safety net (~360px of body height); in portrait + // its extent is 0, which `home_fit_test.dart` asserts. + child: LayoutBuilder( + builder: (context, box) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: box.maxHeight - 48), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Alarm is armed for', + style: TextStyle(fontSize: 16), ), - ), - ), - const SizedBox(height: 24), - if (status.isNotEmpty) - Text( - status, - textAlign: TextAlign.center, - // Bounded: status can be a raw `'Error: $e'`, i.e. an - // arbitrarily long exception toString(). Unbounded, that - // alone added 100-150px and pushed this column past the - // viewport even in portrait. The scroll view above makes - // the overflow recoverable; this keeps it from happening. - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: status.contains('Error') - ? Theme.of(context).colorScheme.error - : Theme.of( - context, - ).extension()!.success, + HomePresenceLine(key: ValueKey(syncEpoch)), + const SizedBox(height: 16), + Text( + time, + style: theme.textTheme.displayLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 24), - // A third distinct key in this Column, for the same reason as - // the history card's below. - StakesCard( - key: ValueKey('stakes-$syncEpoch'), - busy: busy, - onEscaped: onEscaped, - ), - const SizedBox(height: 24), - // Not `ValueKey(syncEpoch)`: the presence line above - // already carries that key in this Column, and two children - // with one key mis-reconcile silently in release -- the old - // presence element was painted here. - HomeHistoryCard(key: ValueKey('history-$syncEpoch')), - if (!configured) ...[ - const SizedBox(height: 16), - Text( - 'Open Settings (gear icon) to connect Firebase.', - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).extension()!.warning, + const SizedBox(height: 8), + Text( + isOverridden + ? 'One-shot early start — back to $baseline ' + 'after it rings' + : 'Every day. It cannot be turned off.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), ), - ), - ], - ], + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: busy ? null : onPickEarlier, + icon: const Icon(Icons.arrow_upward), + label: const Text('Wake me earlier tomorrow'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(height: 24), + if (status.isNotEmpty) ...[ + Text( + status, + textAlign: TextAlign.center, + // Bounded: status can be a raw `'Error: $e'`, i.e. an + // arbitrarily long exception toString(). + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: status.contains('Error') + ? theme.colorScheme.error + : theme.extension()!.success, + ), + ), + const SizedBox(height: 24), + ], + HomeTiles( + syncEpoch: syncEpoch, + busy: busy, + onEscaped: onEscaped, + ), + if (!configured) ...[ + const SizedBox(height: 16), + Text( + 'Open Settings (gear icon) to connect Firebase.', + textAlign: TextAlign.center, + style: TextStyle( + color: theme.extension()!.warning, + ), + ), + ], + ], + ), + ), ), ), ), diff --git a/phone_app/lib/screens/home_history_card.dart b/phone_app/lib/screens/home_history_card.dart index f05cbd5..60c10c2 100644 --- a/phone_app/lib/screens/home_history_card.dart +++ b/phone_app/lib/screens/home_history_card.dart @@ -15,17 +15,23 @@ const int kHistoryShown = 7; /// first session has finished — an empty card would only say "not yet". class HomeHistoryCard extends StatefulWidget { /// Creates the card; [store] is injectable for tests. - const HomeHistoryCard({super.key, this.store}); + const HomeHistoryCard({super.key, this.store, this.emptyText}); /// Session persistence; defaults to SharedPreferences. final SessionStore? store; + /// Shown once loading found no finished morning, instead of nothing. The + /// home screen opens this card from a tile, so a blank sheet needs a + /// sentence. + final String? emptyText; + @override State createState() => _HomeHistoryCardState(); } class _HomeHistoryCardState extends State { List _sessions = const []; + bool _loaded = false; @override void initState() { @@ -36,12 +42,18 @@ class _HomeHistoryCardState extends State { Future _load() async { final history = await (widget.store ?? SessionStore()).history(); if (!mounted) return; - setState(() => _sessions = history.take(kHistoryShown).toList()); + setState(() { + _sessions = history.take(kHistoryShown).toList(); + _loaded = true; + }); } @override Widget build(BuildContext context) { - if (_sessions.isEmpty) return const SizedBox.shrink(); + if (_sessions.isEmpty) { + final empty = widget.emptyText; + return _loaded && empty != null ? Text(empty) : const SizedBox.shrink(); + } final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/phone_app/lib/screens/home_tiles.dart b/phone_app/lib/screens/home_tiles.dart new file mode 100644 index 0000000..5004483 --- /dev/null +++ b/phone_app/lib/screens/home_tiles.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/screens/home_history_card.dart'; +import 'package:wake_alarm_sync/screens/stakes_card.dart'; + +/// The two detail tiles under the alarm time: today's stakes and the last +/// week of mornings. +/// +/// The home screen fits one phone screen with no scrolling, so these cards +/// moved behind a tap instead of stacking under the control. Each opens its +/// full card in a bottom sheet; nothing was dropped. +class HomeTiles extends StatelessWidget { + /// Creates the tiles; [syncEpoch] and [busy] reach the cards they open. + const HomeTiles({ + required this.syncEpoch, + required this.busy, + this.onEscaped, + super.key, + }); + + /// Bumped after every sync; a sheet opened afterwards loads fresh data. + final int syncEpoch; + + /// Disables the escape hatch while the screen is syncing. + final bool busy; + + /// Called after the escape hatch was used, so the screen publishes it. + final VoidCallback? onEscaped; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _Tile( + key: const Key('home-tile-stakes'), + icon: Icons.gavel_outlined, + label: "Today's stakes", + sheet: StakesCard( + key: ValueKey('stakes-$syncEpoch'), + busy: busy, + onEscaped: onEscaped, + emptyText: 'No stakes to show: the native side has not answered.', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _Tile( + key: const Key('home-tile-history'), + icon: Icons.history, + label: 'Last mornings', + sheet: HomeHistoryCard( + key: ValueKey('history-$syncEpoch'), + emptyText: 'No finished morning yet.', + ), + ), + ), + ], + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.label, + required this.sheet, + super.key, + }); + + final IconData icon; + final String label; + final Widget sheet; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + color: theme.colorScheme.surfaceContainerHigh, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: sheet, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 8), + Text(label, textAlign: TextAlign.center, maxLines: 1), + ], + ), + ), + ), + ); + } +} diff --git a/phone_app/lib/screens/stakes_card.dart b/phone_app/lib/screens/stakes_card.dart index 2463976..3a12fa6 100644 --- a/phone_app/lib/screens/stakes_card.dart +++ b/phone_app/lib/screens/stakes_card.dart @@ -25,6 +25,7 @@ class StakesCard extends StatefulWidget { this.nextAlarm, this.onEscaped, this.busy = false, + this.emptyText, }); /// Native stick reader; defaults to the real channel. @@ -45,6 +46,10 @@ class StakesCard extends StatefulWidget { /// Disables the escape while the screen is syncing. final bool busy; + /// Shown once loading found no native stick, instead of nothing. The home + /// screen opens this card from a tile, so a blank sheet needs a sentence. + final String? emptyText; + @override State createState() => _StakesCardState(); } @@ -56,6 +61,7 @@ class _StakesCardState extends State { EnforcementState? _state; AlarmTime? _next; String _message = ''; + bool _loaded = false; DateTime _now() => (widget.now ?? DateTime.now)(); @@ -78,6 +84,7 @@ class _StakesCardState extends State { setState(() { _state = state; _next = next; + _loaded = true; }); } @@ -107,7 +114,10 @@ class _StakesCardState extends State { @override Widget build(BuildContext context) { final state = _state; - if (state == null) return const SizedBox.shrink(); + final empty = widget.emptyText; + if (state == null) { + return _loaded && empty != null ? Text(empty) : const SizedBox.shrink(); + } final theme = Theme.of(context); final warning = theme.extension()?.warning; final view = describeStakes(now: _now(), state: state, nextAlarm: _next); diff --git a/phone_app/test/home_fit_test.dart b/phone_app/test/home_fit_test.dart new file mode 100644 index 0000000..72eeee2 --- /dev/null +++ b/phone_app/test/home_fit_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/home_body.dart'; +import 'package:wake_alarm_sync/screens/home_history_card.dart'; +import 'package:wake_alarm_sync/screens/stakes_card.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/session_store.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'fake_enforcement_channel.dart'; + +/// The home screen on one phone screen, at Pixel 6a geometry: 1080x2400 px at +/// 2.625, status bar 132 px, nav bar 63 px (from `dumpsys window`). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeEnforcementChannel native; + var picked = 0; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + native = FakeEnforcementChannel()..install(); + picked = 0; + }); + + Future pumpHome(WidgetTester tester, {required String status}) async { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625 + ..padding = const FakeViewPadding(top: 132, bottom: 63); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + appBar: AppBar( + title: const Text('Wake Alarm Sync'), + actions: const [ + Icon(Icons.event_available), + Icon(Icons.sick_outlined), + Icon(Icons.settings), + ], + ), + body: HomeBody( + time: '07:00', + baseline: '07:30', + isOverridden: true, + busy: false, + status: status, + configured: false, + onPickEarlier: () => picked++, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + Future seedHistory() async { + final store = SessionStore(); + for (var day = 1; day <= 9; day++) { + await store.archive( + SessionRules.start( + now: DateTime(2026, 9, day, 8), + tags: const SessionTagSettings(), + nfcAvailable: false, + )..weightKg = 70.0 + day, + ); + } + } + + void expectNoVerticalScroll(WidgetTester tester) { + for (final element in find.byType(Scrollable).evaluate()) { + final state = (element as StatefulElement).state as ScrollableState; + if (state.axisDirection == AxisDirection.down || + state.axisDirection == AxisDirection.up) { + expect(state.position.maxScrollExtent, 0, reason: 'vertical scroll'); + } + } + } + + testWidgets('worst case fits one screen with the control reachable', ( + tester, + ) async { + native.state = nativeState(streak: 1, lockedThrough: '2026-10-02'); + await seedHistory(); + await pumpHome(tester, status: 'Error: ${'x' * 400}'); + expect(tester.takeException(), isNull); + expectNoVerticalScroll(tester); + const bottomChrome = 2400 / 2.625 - 63 / 2.625; + final button = find.text('Wake me earlier tomorrow'); + expect(tester.getRect(button).bottom, lessThan(bottomChrome)); + await tester.tap(button); + expect(picked, 1); + }); + + testWidgets('stakes and history sit behind tiles, not on the page', ( + tester, + ) async { + native.state = nativeState(streak: 1, lockedThrough: '2026-10-02'); + await seedHistory(); + await pumpHome(tester, status: ''); + expect(find.byType(StakesCard), findsNothing); + expect(find.byType(HomeHistoryCard), findsNothing); + + await tester.tap(find.byKey(const Key('home-tile-stakes'))); + await tester.pumpAndSettle(); + expect(find.byType(StakesCard), findsOneWidget); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('home-tile-history'))); + await tester.pumpAndSettle(); + expect(find.byType(HomeHistoryCard), findsOneWidget); + expect(find.textContaining('09-09'), findsOneWidget); + }); +} diff --git a/phone_app/test/home_history_card_test.dart b/phone_app/test/home_history_card_test.dart index 7317c6a..c7f4602 100644 --- a/phone_app/test/home_history_card_test.dart +++ b/phone_app/test/home_history_card_test.dart @@ -69,6 +69,15 @@ void main() { expect(find.text('Last mornings'), findsNothing); }); + testWidgets('says so when no morning has finished', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: HomeHistoryCard(emptyText: 'None yet.')), + ); + expect(find.text('None yet.'), findsNothing); + await tester.pump(); + expect(find.text('None yet.'), findsOneWidget); + }); + testWidgets('lists the last seven with weight deltas', (tester) async { final store = SessionStore(); for (var day = 1; day <= 9; day++) { diff --git a/phone_app/test/stakes_card_test.dart b/phone_app/test/stakes_card_test.dart index 6a9168e..ee9baa6 100644 --- a/phone_app/test/stakes_card_test.dart +++ b/phone_app/test/stakes_card_test.dart @@ -179,7 +179,7 @@ void main() { expect(await EscapeLog.load(), isEmpty); }); - testWidgets('the home body carries the card', (tester) async { + testWidgets('the home body opens the card from its tile', (tester) async { native.state = nativeState(); await tester.pumpWidget( MaterialApp( @@ -198,6 +198,21 @@ void main() { ), ); await tester.pump(); + expect(find.byType(StakesCard), findsNothing); + await tester.tap(find.byKey(const Key('home-tile-stakes'))); + await tester.pumpAndSettle(); expect(find.byType(StakesCard), findsOneWidget); }); + + testWidgets('says so when the native side answers nothing', (tester) async { + native.state = null; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StakesCard(emptyText: 'No stakes.')), + ), + ); + expect(find.text('No stakes.'), findsNothing); + await tester.pump(); + expect(find.text('No stakes.'), findsOneWidget); + }); }