mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
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:
parent
18a304de55
commit
6033e3815f
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
107
phone_app/lib/screens/home_tiles.dart
Normal file
107
phone_app/lib/screens/home_tiles.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
120
phone_app/test/home_fit_test.dart
Normal file
120
phone_app/test/home_fit_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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++) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user