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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWcbzoov1KFZJSFcSs
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 22:06:43 +01:00
parent 18a304de55
commit 6033e3815f
7 changed files with 357 additions and 87 deletions

View File

@ -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<int>(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<AppStatusColors>()!.success,
HomePresenceLine(key: ValueKey<int>(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<String>('stakes-$syncEpoch'),
busy: busy,
onEscaped: onEscaped,
),
const SizedBox(height: 24),
// Not `ValueKey<int>(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<String>('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<AppStatusColors>()!.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<AppStatusColors>()!.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<AppStatusColors>()!.warning,
),
),
],
],
),
),
),
),
),

View File

@ -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<HomeHistoryCard> createState() => _HomeHistoryCardState();
}
class _HomeHistoryCardState extends State<HomeHistoryCard> {
List<MorningSession> _sessions = const <MorningSession>[];
bool _loaded = false;
@override
void initState() {
@ -36,12 +42,18 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
Future<void> _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,

View File

@ -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: <Widget>[
Expanded(
child: _Tile(
key: const Key('home-tile-stakes'),
icon: Icons.gavel_outlined,
label: "Today's stakes",
sheet: StakesCard(
key: ValueKey<String>('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<String>('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<void>(
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: <Widget>[
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(label, textAlign: TextAlign.center, maxLines: 1),
],
),
),
),
);
}
}

View File

@ -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<StakesCard> createState() => _StakesCardState();
}
@ -56,6 +61,7 @@ class _StakesCardState extends State<StakesCard> {
EnforcementState? _state;
AlarmTime? _next;
String _message = '';
bool _loaded = false;
DateTime _now() => (widget.now ?? DateTime.now)();
@ -78,6 +84,7 @@ class _StakesCardState extends State<StakesCard> {
setState(() {
_state = state;
_next = next;
_loaded = true;
});
}
@ -107,7 +114,10 @@ class _StakesCardState extends State<StakesCard> {
@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<AppStatusColors>()?.warning;
final view = describeStakes(now: _now(), state: state, nextAlarm: _next);

View File

@ -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(<String, Object>{});
native = FakeEnforcementChannel()..install();
picked = 0;
});
Future<void> 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<void> 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);
});
}

View File

@ -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++) {

View File

@ -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);
});
}