From efe8baa9b46885f3aeb98ead213f8913084237e5 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 9 Sep 2026 00:06:38 +0200 Subject: [PATCH 1/3] feat(chrome): the six full-screen flows wear the design's floating bar (#525, #527) --- docs/CHANGELOG.md | 21 +++ lib/core/widgets/float_topbar.dart | 171 +++++++++++++----- .../presentation/flashcards_screen.dart | 91 ++++++---- .../presentation/term_of_day_screen.dart | 85 +++++---- .../presentation/vocab/vocab_game_screen.dart | 92 ++++++---- .../lessons/presentation/lesson_screen.dart | 70 ++++--- .../presentation/mini_game_intro_screen.dart | 88 ++++++--- .../presentation/mini_game_player_screen.dart | 94 ++++++---- .../settings/settings_destinations.dart | 72 ++++++-- .../settings/settings_sub_screen.dart | 22 ++- ...pushed_pages_wear_the_design_bar_test.dart | 50 +---- .../core/widgets/float_topbar_test.dart | 88 +++++++++ .../features/lessons/lesson_screen_test.dart | 26 ++- .../features/profile/about_screen_test.dart | 105 +++++++++++ 14 files changed, 754 insertions(+), 321 deletions(-) create mode 100644 test/widget/features/profile/about_screen_test.dart diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0938ea23..56528dd2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -61,6 +61,27 @@ You can always edit this file by hand instead — the helpers just save effort. ### Changed +- **A lesson, a mini-game or a drill fills the screen.** All six used to open + under Flutter's own bar, a strip that took its own space at the top of the + page and left a hairline of nothing above it on a notched phone. The bar now + floats over the page: on a lesson, a mini-game or either drill it stays + filled and reaches up behind the status bar, so the card slides under it + cleanly; on the mini-game introduction and Term of the Day it shows nothing + until you scroll, then fades in blurred, the same way Settings and the Atlas + do. What each bar carries is unchanged — the way out, the roasting bean and + its count, and the save or shuffle beside them. + + The mini-game introduction no longer prints *Mini-game* in its bar. The + design keeps a screen's name in the page below, never in the bar, and this + one already names the game underneath. + +- **About opens on the app rather than on the word About.** The page you reach + from Settings put *About* at the top, left-aligned, which named the row you + tapped rather than the thing the page is about. It now opens the way the + design draws it: Roasty, then **BrewPath**, then *A field guide to coffee*, + the three centred together. *About* stays in the bar once you scroll, as on + every other page behind Settings. + - **Today's card is the design's.** A surface card with a rule around it: the module as its eyebrow, the title, the picture, one line saying where the lesson sits and how long it takes (`LESSON 1/7 · ~3 MIN`), and a full-width diff --git a/lib/core/widgets/float_topbar.dart b/lib/core/widgets/float_topbar.dart index 341618a5..c9561590 100644 --- a/lib/core/widgets/float_topbar.dart +++ b/lib/core/widgets/float_topbar.dart @@ -1,32 +1,38 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/header_chrome.dart'; import 'package:brew_path/core/widgets/scrolled_progress.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; -/// A floating close or back control over a full-bleed screen: transparent at -/// rest, standard header chrome once the content has moved under it. +/// The bar over a full-screen flow, on the design's `32px 1fr 32px` grid. /// -/// **Chrome, not content.** It sits above the scroll rather than in it, so it -/// stays reachable on a long ending — and takes a fill only when there is -/// something behind it to separate from, which is what stops a control -/// floating over a celebration from looking like a mistake. -/// -/// The fill is the header's own [MoodColors.headerFill] — the page pulled over -/// itself at 94%, blurred and lifted back to its warmth — because the design -/// writes this bar and the sticky header with the same two constants. It is -/// the whole token, so the blur cannot be left behind the way the first -/// overlay port left four of them behind (#379). +/// It sits above the scroll, so the page passes underneath. Sealed, it hides +/// that page behind the page's own colour; unsealed, it shows nothing until +/// the content has moved, then takes [MoodColors.headerFill]. class FloatTopbar extends StatelessWidget { - /// Creates a [FloatTopbar]. + /// Creates a bar that stays out of the way until the page moves under it. const FloatTopbar({ required this.icon, required this.label, required this.onPressed, required this.isScrolled, + this.centre, + this.trailing, super.key, - }); + }) : _isSealed = false; + + /// Creates a bar that is filled from the first frame. + const FloatTopbar.sealed({ + required this.icon, + required this.label, + required this.onPressed, + this.centre, + this.trailing, + super.key, + }) : _isSealed = true, + isScrolled = false; /// The mark — a close on a screen you leave, a back on one you turn over. final AppIcon icon; @@ -38,54 +44,88 @@ class FloatTopbar extends StatelessWidget { final VoidCallback onPressed; /// Whether the content has scrolled far enough for the bar to take chrome. + /// Always false on a sealed bar, which is filled either way. final bool isScrolled; + /// Where the learner is inside the run, centred in the bar. + final Widget? centre; + + /// The bar's one action — a bookmark, a shuffle. + final Widget? trailing; + + final bool _isSealed; + /// The design's 44×44 header control. static const double hitSize = 44; /// The bar's own height, which is the header's. static const double height = 56; + /// The room a scroll under this bar leaves at its top, so the content opens + /// clear of the bar and still passes beneath it. + /// + /// [designScrollPad] is where the design starts that content, measured from + /// the top of the screen the way the design measures it. + static EdgeInsets scrollPadding( + BuildContext context, { + required double designScrollPad, + }) => EdgeInsets.only( + top: + MediaQuery.paddingOf(context).top + + HeaderChrome.belowDesignStatusBar(designScrollPad), + ); + @override Widget build(BuildContext context) { final mood = context.mood; - return ScrolledProgress( - isScrolled: isScrolled, - duration: scrolledFade, - child: SafeArea( - bottom: false, - child: Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: IconButton( - onPressed: onPressed, - tooltip: label, - constraints: const BoxConstraints.tightFor( - width: hitSize, - height: hitSize, + final controls = SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Row( + children: [ + SizedBox( + width: hitSize, + child: IconButton( + onPressed: onPressed, + tooltip: label, + constraints: const BoxConstraints.tightFor( + width: hitSize, + height: hitSize, + ), + icon: IconMark(icon, color: mood.ink, semanticLabel: label), ), - icon: IconMark(icon, color: mood.ink, semanticLabel: label), ), - ), + Expanded(child: Center(child: centre ?? const SizedBox.shrink())), + // Reserved whether or not it holds anything, so a bar with one + // side control keeps its centre centred. + SizedBox( + width: hitSize, + child: trailing == null + ? null + : Align(alignment: Alignment.centerRight, child: trailing), + ), + ], ), ), + ); + + if (_isSealed) { + return _Sealed(mood: mood, child: controls); + } + + return ScrolledProgress( + isScrolled: isScrolled, + duration: scrolledFade, + child: controls, builder: (context, progress, control) { final headerFill = mood.headerFill.at(progress); - final bar = SizedBox( + final bar = _Band( height: height + MediaQuery.paddingOf(context).top, - child: DecoratedBox( - decoration: BoxDecoration( - color: headerFill.color, - border: Border( - bottom: BorderSide( - color: mood.rule.withValues(alpha: progress), - ), - ), - ), - child: control, - ), + color: headerFill.color, + ruleColor: mood.rule.withValues(alpha: progress), + child: control, ); final filter = headerFill.backdropFilter; @@ -99,3 +139,48 @@ class FloatTopbar extends StatelessWidget { ); } } + +/// The always-filled band: the page's own colour, so nothing shows through and +/// no filter is paid for. +class _Sealed extends StatelessWidget { + const _Sealed({required this.mood, required this.child}); + + final MoodColors mood; + final Widget child; + + @override + Widget build(BuildContext context) => _Band( + height: FloatTopbar.height + MediaQuery.paddingOf(context).top, + color: mood.bg, + ruleColor: mood.rule, + child: child, + ); +} + +/// The bar's painted band, reaching up under the status bar so what passes +/// beneath is covered all the way to the top of the screen. +class _Band extends StatelessWidget { + const _Band({ + required this.height, + required this.color, + required this.ruleColor, + required this.child, + }); + + final double height; + final Color color; + final Color ruleColor; + final Widget? child; + + @override + Widget build(BuildContext context) => SizedBox( + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + border: Border(bottom: BorderSide(color: ruleColor)), + ), + child: child, + ), + ); +} diff --git a/lib/features/dictionary/presentation/flashcards_screen.dart b/lib/features/dictionary/presentation/flashcards_screen.dart index c4f0d5ae..618ad8a8 100644 --- a/lib/features/dictionary/presentation/flashcards_screen.dart +++ b/lib/features/dictionary/presentation/flashcards_screen.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:brew_path/app/day_surfaces.dart'; +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/utils/module_icons.dart'; import 'package:brew_path/core/widgets/drill_results_view.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/roast_meter.dart'; import 'package:brew_path/features/dictionary/domain/flashcard_completion.dart'; @@ -25,16 +27,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Where the design opens a drill's content, measured from the top of the +/// screen — `padding-top: 134`, clear of the bar sealed over it. +const double _designScrollPad = 134; + /// The flashcards drill: the learner's saved terms, one card at a time. /// -/// The deck is watched rather than snapshotted at open, so un-saving a term — -/// here or on another device — takes it out of the round while the round is -/// running. Every move reconciles against the deck first, which is why the -/// round is a value: the screen holds where the learner is, and the deck says -/// what is still there to be. -/// -/// Results are a state of this screen rather than a route of their own, for -/// the same reason a mini-game's are: the count never outlives the review. +/// The deck is watched rather than snapshotted at open, so un-saving a term +/// takes it out of a running round; every move reconciles against the deck +/// first. Results are a state of this screen rather than a route, for the same +/// reason a mini-game's are: the count never outlives the review. class FlashcardsScreen extends ConsumerStatefulWidget { /// Creates a [FlashcardsScreen]. const FlashcardsScreen({super.key}); @@ -122,39 +124,52 @@ class _FlashcardsScreenState extends ConsumerState { final round = _roundFor(cards.length); return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: 'Close', - onPressed: _close, - ), - title: _meter(round), - actions: [ - // Only worth offering when there is more than one order to deal. - if (cards.length > 1 && !round.isFinished) - IconButton( - // `rematch` — "run it back" — rather than the design's own - // shuffle glyph, which the icon set does not carry. The mark - // means the same act here, and the extractor owns the catalog: - // hand-drawing a seventy-fourth icon is how a set stops being - // the design's. - icon: const IconMark(AppIcon.rematch), - tooltip: FlashcardsCopy.shuffle, - onPressed: () => _shuffle(cards.length), + body: Stack( + fit: StackFit.expand, + children: [ + // The room the bar takes, added here rather than inside the views + // below: the results and the empty state are shared with the other + // drills, and neither should know what is sealed over it. + Padding( + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), + child: deck.when( + loading: () => Semantics( + label: 'Loading your deck', + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: 'Your deck could not be loaded', + child: ErrorView(message: '$error'), + ), + data: (cards) => _body(cards, round, pools.asData?.value), ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _close, + centre: _meter(round), + // Only worth offering when there is more than one order to deal. + trailing: cards.length > 1 && !round.isFinished + ? IconButton( + // `rematch` — "run it back" — rather than the design's + // own shuffle glyph, which the icon set does not carry. + icon: const IconMark(AppIcon.rematch), + tooltip: FlashcardsCopy.shuffle, + onPressed: () => _shuffle(cards.length), + ) + : null, + ), + ), ], ), - body: deck.when( - loading: () => Semantics( - label: 'Loading your deck', - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: 'Your deck could not be loaded', - child: ErrorView(message: '$error'), - ), - data: (cards) => _body(cards, round, pools.asData?.value), - ), ); } diff --git a/lib/features/dictionary/presentation/term_of_day_screen.dart b/lib/features/dictionary/presentation/term_of_day_screen.dart index d17e5707..6650344b 100644 --- a/lib/features/dictionary/presentation/term_of_day_screen.dart +++ b/lib/features/dictionary/presentation/term_of_day_screen.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/utils/date_utils.dart'; import 'package:brew_path/core/utils/module_icons.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/primary_button.dart'; +import 'package:brew_path/core/widgets/scroll_flag_scope.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/features/companion/domain/roasty_state.dart'; import 'package:brew_path/features/companion/presentation/roasty.dart'; @@ -29,6 +31,10 @@ import 'package:go_router/go_router.dart'; /// The design's `Roasty size={120}` over the word. const double _companionSize = 120; +/// Where the design opens this page, measured from the top of the screen — +/// `padding-top: 84`, shorter because it opens on a kicker rather than a run. +const double _designScrollPad = 84; + /// The design's `CatGlyph size={15}` in the category kicker. const double _kickerGlyphSize = 15; @@ -45,36 +51,48 @@ class TermOfDayScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final view = ref.watch(termOfDayViewProvider); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: 'Close', - onPressed: context.pop, - ), - actions: [ - if (view.asData?.value case final resolved?) - SavedBookmarkButton( - savedKey: formatSavedKey(SavedKind.term, resolved.term.id), - label: resolved.term.term, + return ScrollFlagScope( + builder: (context, {required isScrolled}) => Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + view.when( + loading: () => Semantics( + label: "Loading today's term", + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: "Today's term could not be loaded", + child: ErrorView(message: '$error'), + ), + // Nothing to offer: the pool is empty, which the banner that + // leads here would already have hidden itself for. Reachable + // only by a deep link, so it says so rather than showing an + // empty page. + data: (resolved) => resolved == null + ? const ErrorView(message: 'There is no term for today.') + : _TermOfDay(view: resolved), ), - ], - ), - body: view.when( - loading: () => Semantics( - label: "Loading today's term", - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: "Today's term could not be loaded", - child: ErrorView(message: '$error'), + Positioned( + top: 0, + left: 0, + right: 0, + child: FloatTopbar( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: context.pop, + isScrolled: isScrolled, + trailing: switch (view.asData?.value) { + final resolved? => SavedBookmarkButton( + savedKey: formatSavedKey(SavedKind.term, resolved.term.id), + label: resolved.term.term, + ), + null => null, + }, + ), + ), + ], ), - // Nothing to offer: the pool is empty, which the banner that leads - // here would already have hidden itself for. Reachable only by a deep - // link, so it says so rather than showing an empty page. - data: (resolved) => resolved == null - ? const ErrorView(message: 'There is no term for today.') - : _TermOfDay(view: resolved), ), ); } @@ -104,9 +122,12 @@ class _TermOfDay extends StatelessWidget { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.gutter, - ), + padding: + const EdgeInsets.symmetric(horizontal: AppSpacing.gutter) + + FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), child: Column( children: [ SmallcapsLabel(TermOfDayCopy.title, color: mood.accent), diff --git a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart index 59122d92..fabfb659 100644 --- a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart +++ b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart @@ -4,10 +4,10 @@ import 'package:brew_path/app/day_surfaces.dart'; import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/utils/drill_bands.dart'; import 'package:brew_path/core/widgets/drill_results_view.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/roast_meter.dart'; import 'package:brew_path/features/dictionary/domain/vocab_completion.dart'; @@ -24,16 +24,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Where the design opens a drill's content, measured from the top of the +/// screen — `padding-top: 134`, clear of the bar sealed over it. +const double _designScrollPad = 134; + /// *Guess the term* — setup, the rounds, then the score. /// -/// The whole drill is three states of one screen rather than three routes: the -/// score never outlives the run, so routing to it would mean handing a number -/// to the router only to hand it straight back — the same shape the mini-game -/// player takes. -/// -/// The drill holds a single seed, minted when the rounds are dealt and -/// re-minted by Play again, which decides the terms asked, the wrong answers -/// offered, and the order of the four options. Nothing about it is persisted. +/// Three states of one screen rather than three routes, the shape the +/// mini-game player takes: the score never outlives the run. One seed, minted +/// at the deal and re-minted by Play again, decides the terms asked, the wrong +/// answers offered and the order of the options; none of it is persisted. class VocabGameScreen extends ConsumerStatefulWidget { /// Creates a [VocabGameScreen]. const VocabGameScreen({super.key}); @@ -129,11 +129,9 @@ class _VocabGameScreenState extends ConsumerState { /// Leaves the drill, back to wherever it was opened from. /// - /// Both entry points push, so the usual answer is a pop — which returns the - /// learner to the dictionary they were browsing rather than stranding them - /// on Today having lost their place. Keep Sharp's CTA *goes* rather than - /// pushes, like every other recommendation destination, and that is the case - /// the fallback is for. + /// Both entry points push, so a pop returns the learner to the dictionary + /// they were browsing rather than stranding them on Today. Keep Sharp's CTA + /// *goes* rather than pushes, which is what the fallback is for. void _done() { final router = GoRouter.of(context); if (router.canPop()) { @@ -168,31 +166,47 @@ class _VocabGameScreenState extends ConsumerState { final total = _rounds.length; return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: AppLabels.close, - onPressed: _done, - ), - title: _playing && _index < total - ? RoastMeter( - position: _index + 1, - total: total, - semanticsLabel: VocabCopy.progress(_index + 1, total), - ) - : null, - ), - body: pools.when( - loading: () => Semantics( - label: VocabCopy.loading, - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: VocabCopy.loadFailed, - excludeSemantics: true, - child: ErrorView(message: '$error'), - ), - data: _buildDrill, + body: Stack( + fit: StackFit.expand, + children: [ + // The room the bar takes. Added here rather than inside the drill, + // whose results view is shared with the other two runs. + Padding( + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), + child: pools.when( + loading: () => Semantics( + label: VocabCopy.loading, + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: VocabCopy.loadFailed, + excludeSemantics: true, + child: ErrorView(message: '$error'), + ), + data: _buildDrill, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _done, + centre: _playing && _index < total + ? RoastMeter( + position: _index + 1, + total: total, + semanticsLabel: VocabCopy.progress(_index + 1, total), + ) + : null, + ), + ), + ], ), ); } diff --git a/lib/features/lessons/presentation/lesson_screen.dart b/lib/features/lessons/presentation/lesson_screen.dart index fa20f1f9..75426453 100644 --- a/lib/features/lessons/presentation/lesson_screen.dart +++ b/lib/features/lessons/presentation/lesson_screen.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/roast_meter.dart'; import 'package:brew_path/features/lessons/domain/card_seed.dart'; @@ -18,6 +18,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Where the design opens a run's content, measured from the top of the +/// screen — `padding-top: 134`, clear of the bar sealed over it. +const double _designScrollPad = 134; + /// Immersive single-lesson flow: plays each card, then routes to completion. class LessonScreen extends ConsumerStatefulWidget { /// Creates a [LessonScreen]. @@ -96,40 +100,44 @@ class _LessonScreenState extends ConsumerState { return FutureBuilder( future: _lesson, builder: (context, snapshot) => Scaffold( - appBar: AppBar( - // The player is a surface you leave, not a page you came from: the - // design gives it a close mark where a pushed screen would have a - // back arrow. - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () => context.pop(), - ), - title: _position(snapshot.data), - actions: [ - // The design bookmarks a lesson **while it is being read**, not - // off a list afterwards. - if (snapshot.data case final lesson?) - SavedBookmarkButton( - savedKey: formatSavedKey(SavedKind.lesson, lesson.id), - label: lesson.title, + body: Stack( + fit: StackFit.expand, + children: [ + _buildBody(context, snapshot), + Positioned( + top: 0, + left: 0, + right: 0, + // The player is a surface you leave, not a page you came from: + // the design gives it a close mark where a pushed screen would + // have a back arrow. + child: FloatTopbar.sealed( + icon: AppIcon.close, + label: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => context.pop(), + centre: _position(snapshot.data), + // The design bookmarks a lesson **while it is being read**, + // not off a list afterwards. + trailing: switch (snapshot.data) { + final lesson? => SavedBookmarkButton( + savedKey: formatSavedKey(SavedKind.lesson, lesson.id), + label: lesson.title, + ), + null => null, + }, ), + ), ], ), - body: _buildBody(context, snapshot), ), ); } /// The bar's centre: where the learner is, once there is a lesson to be in. /// - /// The design puts the position *in the bar* and nothing else with it — no - /// lesson title, no module eyebrow, because the card is the screen. It is - /// the same [RoastMeter] the mini-game - /// player mounts, so the app has one idea of how-far-through. - /// - /// Null while the lesson is still loading — the bar keeps its close mark and - /// shows nothing else, rather than a position out of thin air. + /// The position and nothing else — no title, no eyebrow, because the card is + /// the screen. Null while the lesson loads, rather than a position out of + /// thin air. Widget? _position(LessonModel? lesson) { if (lesson == null) return null; @@ -178,9 +186,17 @@ class _LessonScreenState extends ConsumerState { onContinue: () => _onContinue(lesson), ); + // Bottom only: the bar covers the top inset itself, and the scroll's own + // padding starts the card below it while letting it pass underneath. return SafeArea( + top: false, child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: + const EdgeInsets.all(AppSpacing.lg) + + FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/features/mini_games/presentation/mini_game_intro_screen.dart b/lib/features/mini_games/presentation/mini_game_intro_screen.dart index 4b096a8d..99cc938f 100644 --- a/lib/features/mini_games/presentation/mini_game_intro_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_intro_screen.dart @@ -1,9 +1,11 @@ +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/primary_button.dart'; +import 'package:brew_path/core/widgets/scroll_flag_scope.dart'; import 'package:brew_path/features/mini_games/domain/mini_game_providers.dart'; import 'package:brew_path/features/mini_games/domain/mini_game_run.dart'; import 'package:brew_path/shared/models/content/mini_game_format.dart'; @@ -13,6 +15,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Where the design opens this page, measured from the top of the screen — +/// `padding-top: 108`. +const double _designScrollPad = 108; + /// What the game is and how it is played, before any round runs. /// /// Backing out here costs nothing: no run has begun, so there is nothing to @@ -28,37 +34,53 @@ class MiniGameIntroScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final format = ref.watch(miniGameFormatProvider(formatId)); - return Scaffold( - appBar: AppBar( - title: const Text('Mini-game'), - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: 'Close', - onPressed: () => context.canPop() - ? context.pop() - : context.goNamed(AppRoutes.learn.name), + return ScrollFlagScope( + builder: (context, {required isScrolled}) => Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + _intro(format), + Positioned( + top: 0, + left: 0, + right: 0, + // No title: the screen's own name is the heading in the body + // below, which is where the design keeps it. + child: FloatTopbar( + icon: AppIcon.close, + label: AppLabels.close, + isScrolled: isScrolled, + onPressed: () => context.canPop() + ? context.pop() + : context.goNamed(AppRoutes.learn.name), + ), + ), + ], ), ), - body: format.when( - loading: () => Semantics( - label: 'Loading the mini-game', - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: 'That mini-game could not be loaded.', - excludeSemantics: true, - child: ErrorView(message: '$error'), - ), - data: (data) => data == null - ? Semantics( - label: 'That mini-game is not in the catalog.', - excludeSemantics: true, - child: const ErrorView( - message: 'That mini-game is not in the catalog.', - ), - ) - : _Intro(format: data), + ); + } + + Widget _intro(AsyncValue format) { + return format.when( + loading: () => Semantics( + label: 'Loading the mini-game', + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: 'That mini-game could not be loaded.', + excludeSemantics: true, + child: ErrorView(message: '$error'), ), + data: (data) => data == null + ? Semantics( + label: 'That mini-game is not in the catalog.', + excludeSemantics: true, + child: const ErrorView( + message: 'That mini-game is not in the catalog.', + ), + ) + : _Intro(format: data), ); } } @@ -76,11 +98,17 @@ class _Intro extends StatelessWidget { final mood = context.mood; return SafeArea( + top: false, child: Column( children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: + const EdgeInsets.all(AppSpacing.lg) + + FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/mini_games/presentation/mini_game_player_screen.dart b/lib/features/mini_games/presentation/mini_game_player_screen.dart index 629170be..e48ba5f6 100644 --- a/lib/features/mini_games/presentation/mini_game_player_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_player_screen.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'package:brew_path/app/day_surfaces.dart'; +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; -import 'package:brew_path/core/icons/icon_mark.dart'; import 'package:brew_path/core/utils/drill_bands.dart'; import 'package:brew_path/core/widgets/drill_results_view.dart'; import 'package:brew_path/core/widgets/error_view.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/roast_meter.dart'; import 'package:brew_path/features/lessons/presentation/cards/content_card_view.dart'; @@ -21,15 +22,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +/// Where the design opens a run's content, measured from the top of the +/// screen — `padding-top: 134`, clear of the bar sealed over it. +const double _designScrollPad = 134; + /// Runs one mini-game: its rounds in this run's order, then the results. /// -/// The run holds a single nonce, minted when it begins and re-minted by Play -/// again, which decides both the round order and each round's choice order. -/// Nothing about the run is persisted. -/// -/// Results are a state of this screen rather than a route of their own: the -/// score never outlives the run, so routing to it would mean handing a number -/// to the router only to hand it straight back. +/// One nonce per run — re-minted by Play again — decides the round order and +/// each round's choices; nothing about it is persisted. The results are a +/// state of this screen rather than a route, because the score never outlives +/// the run. class MiniGamePlayerScreen extends ConsumerStatefulWidget { /// Creates a [MiniGamePlayerScreen]. const MiniGamePlayerScreen({required this.formatId, super.key}); @@ -103,35 +105,47 @@ class _MiniGamePlayerScreenState extends ConsumerState { final rounds = ref.watch(miniGameRoundsProvider(widget.formatId)); return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: 'Close', - onPressed: _done, - ), - title: rounds.maybeWhen( - data: (data) => data.isEmpty || _index >= data.length - ? null - : RoastMeter( - position: _index + 1, - total: data.length, - semanticsLabel: 'Round ${_index + 1} of ${data.length}', - ), - orElse: () => null, - ), + body: Stack( + fit: StackFit.expand, + children: [ + _rounds(rounds), + Positioned( + top: 0, + left: 0, + right: 0, + child: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _done, + centre: rounds.maybeWhen( + data: (data) => data.isEmpty || _index >= data.length + ? null + : RoastMeter( + position: _index + 1, + total: data.length, + semanticsLabel: 'Round ${_index + 1} of ${data.length}', + ), + orElse: () => null, + ), + ), + ), + ], ), - body: rounds.when( - loading: () => Semantics( - label: 'Loading the rounds', - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: 'These rounds could not be loaded.', - excludeSemantics: true, - child: ErrorView(message: '$error'), - ), - data: _buildRun, + ); + } + + Widget _rounds(AsyncValue> rounds) { + return rounds.when( + loading: () => Semantics( + label: 'Loading the rounds', + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: 'These rounds could not be loaded.', + excludeSemantics: true, + child: ErrorView(message: '$error'), ), + data: _buildRun, ); } @@ -172,9 +186,17 @@ class _MiniGamePlayerScreenState extends ConsumerState { onSolved: _onSolved, onContinue: _onContinue, ); + // Bottom only: the bar covers the top inset, and the scroll's own padding + // opens the round below it while letting it pass underneath. return SafeArea( + top: false, child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: + const EdgeInsets.all(AppSpacing.lg) + + FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + ), // Keyed by round so each round mounts a fresh card: a latched card // must never be reused for the next statement. child: KeyedSubtree(key: ValueKey('${_nonce}_$_index'), child: card), diff --git a/lib/features/profile/presentation/settings/settings_destinations.dart b/lib/features/profile/presentation/settings/settings_destinations.dart index 60ae8a86..92259e16 100644 --- a/lib/features/profile/presentation/settings/settings_destinations.dart +++ b/lib/features/profile/presentation/settings/settings_destinations.dart @@ -1,20 +1,17 @@ /// The four screens the design's `ACCOUNT` and `SUPPORT` rows lead to. /// -/// **They are frames, not features.** Each is reached from a row the design -/// draws, so no row on Settings is dead; behind the row is the screen's real -/// title and its real sections, with the parts the app has not built named -/// rather than left blank. Two of them are waiting on seams that are -/// deliberately closed — the payments service is a no-op and Firebase is gated -/// off — and neither is opened here. -/// -/// Help is the exception that is already real: the App Guide row lives here, -/// which is where the design files it. It sat on the Settings root only because -/// this screen did not exist yet, which its own comment said at the time. +/// **They are frames, not features.** Behind each row is the screen's real +/// sections, with what the app has not built named rather than left blank; the +/// payments service is a no-op and Firebase is gated off, and neither seam is +/// opened here. Help is already real, and files the App Guide row. library; +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/widgets/settings_nav_row.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; +import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; import 'package:brew_path/features/profile/domain/settings_providers.dart'; import 'package:brew_path/features/profile/presentation/settings/settings_copy.dart'; import 'package:brew_path/features/profile/presentation/settings/settings_sub_screen.dart'; @@ -101,18 +98,19 @@ class AboutScreen extends ConsumerWidget { return SettingsSubScreen( title: SettingsCopy.aboutTitle, + // The page is about the app, so it opens on the app — not on the menu + // row that reached it. `About` stays in the bar, as on every other page + // behind Settings. + opening: const _BrandBlock(), children: [ - // The design's brand block: the kicker under the app's name, then what - // the app is. - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.gutter), - child: SmallcapsLabel(SettingsCopy.aboutTagline), - ), - const SizedBox(height: AppSpacing.sm), + const SizedBox(height: AppSpacing.lg), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter), + // Centred with the block above it, which is how the design sets the + // whole opening — the fine print below returns to the left. child: Text( SettingsCopy.aboutBlurb, + textAlign: TextAlign.center, style: AppText.body(mood: mood, color: mood.inkMute), ), ), @@ -126,3 +124,43 @@ class AboutScreen extends ConsumerWidget { ); } } + +/// What About opens on: the mascot, the app's name, and what it is. +/// +/// Centred as one block — the one place in Settings that departs from the +/// left-aligned heading its four screens share. +class _BrandBlock extends StatelessWidget { + const _BrandBlock(); + + /// The design's `Roasty size={132}`. + static const double _companionSize = 132; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter), + child: Column( + children: [ + // Named by the app's name below it; the drawing says nothing a + // reader can act on. + const ExcludeSemantics( + child: Roasty(state: RoastyState.idle, size: _companionSize), + ), + const SizedBox(height: AppSpacing.base), + Semantics( + header: true, + child: Text( + AppLabels.appName, + textAlign: TextAlign.center, + style: AppText.display(mood: mood), + ), + ), + const SizedBox(height: AppSpacing.xs), + const SmallcapsLabel(SettingsCopy.aboutTagline), + ], + ), + ); + } +} diff --git a/lib/features/profile/presentation/settings/settings_sub_screen.dart b/lib/features/profile/presentation/settings/settings_sub_screen.dart index dd78985c..f9a69df1 100644 --- a/lib/features/profile/presentation/settings/settings_sub_screen.dart +++ b/lib/features/profile/presentation/settings/settings_sub_screen.dart @@ -18,12 +18,18 @@ class SettingsSubScreen extends StatelessWidget { const SettingsSubScreen({ required this.title, required this.children, + this.opening, super.key, }); - /// The screen's name, in the bar and as its heading. + /// The screen's name, in the bar and — unless [opening] says otherwise — as + /// its heading. final String title; + /// What the page opens on, where its own name is not the right heading. + /// About opens on the app rather than on the menu row that reached it. + final Widget? opening; + /// The sections, in the design's order. final List children; @@ -34,7 +40,7 @@ class SettingsSubScreen extends StatelessWidget { body: (context, scrollPadding) => ListView( padding: scrollPadding.copyWith(bottom: AppSpacing.xl), children: [ - SettingsScreenHeading(title: title), + opening ?? SettingsScreenHeading(title: title), ...children, ], ), @@ -125,14 +131,10 @@ class SettingsPlaceholder extends StatelessWidget { /// The centred mono line that closes Settings and About. /// -/// The design ends both screens this way rather than with a labelled row: the -/// app's name, its version and [SettingsCopy.versionTagline], separated by -/// middots — mono smallcaps, centred, in muted ink. The app had it as an -/// `About` section with a stock info glyph on a `ListTile`, which is a row -/// where the design has a signature. -/// -/// The line is not spelled out here: the glossary guard reads comments too, -/// and the tagline is the one phrase it allows by name. +/// The design ends both screens with a signature rather than a labelled row: +/// the app's name, its version and [SettingsCopy.versionTagline] between +/// middots, mono smallcaps in muted ink. Not spelled out here, because the +/// glossary guard reads comments too. class SettingsVersionLine extends StatelessWidget { /// Creates the version line for [version]. const SettingsVersionLine({required this.version, super.key}); diff --git a/test/unit/pushed_pages_wear_the_design_bar_test.dart b/test/unit/pushed_pages_wear_the_design_bar_test.dart index a3b8a92b..9d814a51 100644 --- a/test/unit/pushed_pages_wear_the_design_bar_test.dart +++ b/test/unit/pushed_pages_wear_the_design_bar_test.dart @@ -2,35 +2,12 @@ import 'package:flutter_test/flutter_test.dart'; import '../support/dart_sources.dart'; -// A page opened from a tab wears the design's bar, not Material's. Fifteen -// screens each answered that their own way, which is how the app came to have -// fifteen stock `AppBar`s where the design has one bar composed twice (#513). -// The sweep is a one-off; this keeps it swept. +// No screen draws Material's bar. Fifteen answered that their own way (#513), +// and the six full-screen flows that were exempted while the floating bar was +// unbuilt now wear it too (#525). The sweep is a one-off; this keeps it swept. void main() { - /// The screens still allowed to draw one, and why. All six are full-screen - /// flows rather than pages opened from a tab, so they want the design's - /// *floating* bar rather than its back bar — a different component and a - /// different ticket (#525). Listed rather than pattern-matched, so converting - /// one fails here and the list cannot go stale after that ticket lands. - const sanctioned = { - 'lib/features/lessons/presentation/lesson_screen.dart': - 'the lesson player: close, the bean and its count, save — #395 ' - 'settled what it carries, #525 owns the chrome under it', - 'lib/features/mini_games/presentation/mini_game_intro_screen.dart': - 'a full-screen flow, waiting on #525', - 'lib/features/mini_games/presentation/mini_game_player_screen.dart': - 'a full-screen flow, waiting on #525', - 'lib/features/dictionary/presentation/vocab/vocab_game_screen.dart': - 'a full-screen flow, waiting on #525', - 'lib/features/dictionary/presentation/flashcards_screen.dart': - 'a full-screen flow, waiting on #525', - 'lib/features/dictionary/presentation/term_of_day_screen.dart': - 'a full-screen flow, waiting on #525', - }; - test('no page opened from a tab hand-rolls a bar', () { final offenders = dartSourcesUnder('lib') - .where((file) => !sanctioned.containsKey(file.path)) .where( (file) => withoutComments(file.readAsStringSync()).contains('AppBar('), @@ -49,25 +26,4 @@ void main() { '${offenders.join('\n')}', ); }); - - test('every sanctioned screen still draws one', () { - // An exemption nobody needs is a hole in the guard: once #525 converts a - // screen, its entry has to come out. - for (final entry in sanctioned.entries) { - final source = withoutComments( - dartSourcesUnder( - 'lib', - ).firstWhere((file) => file.path == entry.key).readAsStringSync(), - ); - - expect( - source.contains('AppBar('), - isTrue, - reason: - '${entry.key} is exempted as "${entry.value}" but no longer draws ' - 'an AppBar — drop the exemption rather than leaving a hole in the ' - 'guard', - ); - } - }); } diff --git a/test/widget/core/widgets/float_topbar_test.dart b/test/widget/core/widgets/float_topbar_test.dart index 781098e1..06bb2e8e 100644 --- a/test/widget/core/widgets/float_topbar_test.dart +++ b/test/widget/core/widgets/float_topbar_test.dart @@ -108,4 +108,92 @@ void main() { expect(find.byTooltip('Flip back'), findsOneWidget); }); }); + + group('a sealed bar', () { + testWidgets('is filled from the first frame, never scroll-dependent', ( + tester, + ) async { + await tester.pumpWidget( + _host( + FloatTopbar.sealed( + icon: AppIcon.close, + label: 'Close', + onPressed: () {}, + ), + ), + ); + + expect(_fill(tester), MoodColors.darkRoast.bg); + }); + + testWidgets('pays for no filter — an opaque page hides what passes under', ( + tester, + ) async { + await tester.pumpWidget( + _host( + FloatTopbar.sealed( + icon: AppIcon.close, + label: 'Close', + onPressed: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + }); + }); + + group('the grid', () { + testWidgets('carries a centre and a trailing control', (tester) async { + await tester.pumpWidget( + _host( + FloatTopbar.sealed( + icon: AppIcon.close, + label: 'Close', + onPressed: () {}, + centre: const Text('01 / 08'), + trailing: IconButton( + onPressed: () {}, + tooltip: 'Save', + icon: const Icon(Icons.bookmark_border), + ), + ), + ), + ); + + expect(find.text('01 / 08'), findsOneWidget); + expect(find.byTooltip('Save'), findsOneWidget); + }); + + testWidgets('centres the middle even with no trailing control', ( + tester, + ) async { + await tester.pumpWidget( + _host( + FloatTopbar.sealed( + icon: AppIcon.close, + label: 'Close', + onPressed: () {}, + centre: const Text('01 / 08'), + ), + ), + ); + + // The design reserves the third column whether or not it holds + // anything, so a bar with one side control does not push its centre off + // centre. + final bar = tester.getRect(find.byType(FloatTopbar)); + expect( + tester.getCenter(find.text('01 / 08')).dx, + moreOrLessEquals(bar.center.dx, epsilon: 0.5), + ); + }); + }); } diff --git a/test/widget/features/lessons/lesson_screen_test.dart b/test/widget/features/lessons/lesson_screen_test.dart index 846503e9..7a7a9c30 100644 --- a/test/widget/features/lessons/lesson_screen_test.dart +++ b/test/widget/features/lessons/lesson_screen_test.dart @@ -1,10 +1,12 @@ import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/features/lessons/presentation/lesson_screen.dart'; import 'package:brew_path/features/saved/presentation/saved_bookmark_button.dart'; import 'package:brew_path/shared/models/lesson_model.dart'; import 'package:brew_path/shared/repositories/content_repository.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -88,7 +90,7 @@ void main() { await pumpLesson(tester, testLesson()); Finder markInBar(AppIcon icon) => find.descendant( - of: find.byType(AppBar), + of: find.byType(FloatTopbar), matching: find.byWidgetPredicate( (widget) => widget is IconMark && widget.icon == icon, ), @@ -107,13 +109,33 @@ void main() { expect( find.descendant( - of: find.byType(AppBar), + of: find.byType(FloatTopbar), matching: find.byType(SavedBookmarkButton), ), findsOneWidget, ); }); + testWidgets('seals the band over the card, from the first frame', ( + tester, + ) async { + // The card scrolls under the bar rather than starting below it, so the + // fill is what keeps it from showing through at the top of the screen. + await pumpLesson(tester, testLesson()); + + expect(find.byType(AppBar), findsNothing); + final band = tester.widget( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(DecoratedBox), + ), + ); + expect( + (band.decoration as BoxDecoration).color, + MoodColors.darkRoast.bg, + ); + }); + testWidgets('reports position without ever reporting a score', ( tester, ) async { diff --git a/test/widget/features/profile/about_screen_test.dart b/test/widget/features/profile/about_screen_test.dart new file mode 100644 index 00000000..0a333cd5 --- /dev/null +++ b/test/widget/features/profile/about_screen_test.dart @@ -0,0 +1,105 @@ +import 'package:brew_path/core/constants/app_labels.dart'; +import 'package:brew_path/core/widgets/smallcaps_label.dart'; +import 'package:brew_path/core/widgets/sub_header.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; +import 'package:brew_path/features/profile/presentation/settings/settings_copy.dart'; +import 'package:brew_path/features/profile/presentation/settings/settings_destinations.dart'; +import 'package:brew_path/features/profile/presentation/settings/settings_sub_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/widget_harness.dart'; + +/// The kicker is rendered uppercase, so it is found by what it was given +/// rather than by what it draws. +final Finder _tagline = find.byWidgetPredicate( + (widget) => + widget is SmallcapsLabel && widget.text == SettingsCopy.aboutTagline, +); + +void main() { + setUp(useInMemoryDatabase); + + /// Bounded pumps rather than `pumpAndSettle`: About mounts Roasty, whose + /// idle animation never ends. + Future pump(WidgetTester tester, Widget screen) async { + tester.view.physicalSize = const Size(400, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope(child: MaterialApp(home: screen)), + ); + await tester.pump(); + for (var attempt = 0; attempt < 30; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty && + attempt >= 2) { + break; + } + } + } + + testWidgets('opens on the app, not on the row that reached it', ( + tester, + ) async { + await pump(tester, const AboutScreen()); + + expect(find.byType(Roasty), findsOneWidget); + expect(find.text(AppLabels.appName), findsOneWidget); + expect(_tagline, findsOneWidget); + expect( + find.byType(SettingsScreenHeading), + findsNothing, + reason: 'the page is about the app, so About is not its heading', + ); + }); + + testWidgets('keeps About in the bar, as every page behind Settings does', ( + tester, + ) async { + await pump(tester, const AboutScreen()); + + expect( + tester.widget(find.byType(SubHeader)).title, + SettingsCopy.aboutTitle, + ); + }); + + testWidgets('centres the mascot, the name and the tagline as one block', ( + tester, + ) async { + await pump(tester, const AboutScreen()); + + final centres = [ + tester.getCenter(find.byType(Roasty)).dx, + tester.getCenter(find.text(AppLabels.appName)).dx, + tester.getCenter(_tagline).dx, + ]; + + for (final centre in centres) { + expect(centre, moreOrLessEquals(centres.first, epsilon: 0.5)); + } + }); + + testWidgets('the other screens behind Settings still open on their name', ( + tester, + ) async { + // About is the one that wants a different opening; the frame it shares + // with the other three is unchanged. + await pump(tester, const HelpSupportScreen()); + + expect(find.byType(SettingsScreenHeading), findsOneWidget); + expect( + tester + .widget(find.byType(SettingsScreenHeading)) + .title, + SettingsCopy.helpTitle, + ); + }); +} From 790c2d908da784699dc0b26c1ff409b6dfb18717 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 9 Sep 2026 00:18:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(chrome):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20the=20results=20view's=20room,=20and=20the=20pad=20?= =?UTF-8?q?the=20design=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/float_topbar.dart | 57 ++++++++++-------- .../presentation/flashcards_screen.dart | 15 ++--- .../presentation/term_of_day_screen.dart | 11 ++-- .../presentation/vocab/vocab_game_screen.dart | 13 +---- .../lessons/presentation/lesson_screen.dart | 18 +++--- .../presentation/mini_game_intro_screen.dart | 11 ++-- .../presentation/mini_game_player_screen.dart | 20 +++---- .../unit/flows_seal_the_status_band_test.dart | 53 +++++++++++++++++ .../core/widgets/float_topbar_test.dart | 58 +++++++++++++++++++ test/widget/mini_game_flow_test.dart | 23 ++++++++ 10 files changed, 197 insertions(+), 82 deletions(-) create mode 100644 test/unit/flows_seal_the_status_band_test.dart diff --git a/lib/core/widgets/float_topbar.dart b/lib/core/widgets/float_topbar.dart index c9561590..460a2b5e 100644 --- a/lib/core/widgets/float_topbar.dart +++ b/lib/core/widgets/float_topbar.dart @@ -61,20 +61,37 @@ class FloatTopbar extends StatelessWidget { /// The bar's own height, which is the header's. static const double height = 56; - /// The room a scroll under this bar leaves at its top, so the content opens - /// clear of the bar and still passes beneath it. + /// Where the design opens a run's content, measured from the top of the + /// screen: `padding-top: 134` on a lesson, a mini-game and both drills. + static const double runDesignScrollPad = 134; + + /// The whole padding for a scroll this bar floats over: [inset] on the + /// sides and the foot, and a top that opens the content where the design + /// opens it — [designScrollPad], measured from the top of the screen. /// - /// [designScrollPad] is where the design starts that content, measured from - /// the top of the screen the way the design measures it. + /// Given whole rather than added to the caller's own insets, because adding + /// them lands the content a gutter below the design's own mark. static EdgeInsets scrollPadding( BuildContext context, { required double designScrollPad, - }) => EdgeInsets.only( - top: - MediaQuery.paddingOf(context).top + + double inset = 0, + }) => EdgeInsets.fromLTRB( + inset, + MediaQuery.paddingOf(context).top + HeaderChrome.belowDesignStatusBar(designScrollPad), + inset, + inset, ); + /// The band the bar itself covers, for a body that brings its own gutter. + /// + /// Left outside that body rather than inside its scroll, which is the only + /// place it can go when the body is shared with screens wearing no bar. It + /// stops at the hairline, so content still leaves at the bar's own edge + /// rather than at a line below it. + static EdgeInsets barRoom(BuildContext context) => + EdgeInsets.only(top: MediaQuery.paddingOf(context).top + height); + @override Widget build(BuildContext context) { final mood = context.mood; @@ -111,8 +128,15 @@ class FloatTopbar extends StatelessWidget { ), ); + // Filled with the page's own colour, so nothing shows through and no + // filter is paid for. if (_isSealed) { - return _Sealed(mood: mood, child: controls); + return _Band( + height: height + MediaQuery.paddingOf(context).top, + color: mood.bg, + ruleColor: mood.rule, + child: controls, + ); } return ScrolledProgress( @@ -140,23 +164,6 @@ class FloatTopbar extends StatelessWidget { } } -/// The always-filled band: the page's own colour, so nothing shows through and -/// no filter is paid for. -class _Sealed extends StatelessWidget { - const _Sealed({required this.mood, required this.child}); - - final MoodColors mood; - final Widget child; - - @override - Widget build(BuildContext context) => _Band( - height: FloatTopbar.height + MediaQuery.paddingOf(context).top, - color: mood.bg, - ruleColor: mood.rule, - child: child, - ); -} - /// The bar's painted band, reaching up under the status bar so what passes /// beneath is covered all the way to the top of the screen. class _Band extends StatelessWidget { diff --git a/lib/features/dictionary/presentation/flashcards_screen.dart b/lib/features/dictionary/presentation/flashcards_screen.dart index 618ad8a8..e9ef53c6 100644 --- a/lib/features/dictionary/presentation/flashcards_screen.dart +++ b/lib/features/dictionary/presentation/flashcards_screen.dart @@ -27,10 +27,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Where the design opens a drill's content, measured from the top of the -/// screen — `padding-top: 134`, clear of the bar sealed over it. -const double _designScrollPad = 134; - /// The flashcards drill: the learner's saved terms, one card at a time. /// /// The deck is watched rather than snapshotted at open, so un-saving a term @@ -127,14 +123,11 @@ class _FlashcardsScreenState extends ConsumerState { body: Stack( fit: StackFit.expand, children: [ - // The room the bar takes, added here rather than inside the views - // below: the results and the empty state are shared with the other - // drills, and neither should know what is sealed over it. + // Added here rather than inside the views below: the results and the + // empty state are shared with the other drills, and neither should + // know what is sealed over it. Padding( - padding: FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: FloatTopbar.barRoom(context), child: deck.when( loading: () => Semantics( label: 'Loading your deck', diff --git a/lib/features/dictionary/presentation/term_of_day_screen.dart b/lib/features/dictionary/presentation/term_of_day_screen.dart index 6650344b..1b1a2cdb 100644 --- a/lib/features/dictionary/presentation/term_of_day_screen.dart +++ b/lib/features/dictionary/presentation/term_of_day_screen.dart @@ -122,12 +122,11 @@ class _TermOfDay extends StatelessWidget { children: [ Expanded( child: SingleChildScrollView( - padding: - const EdgeInsets.symmetric(horizontal: AppSpacing.gutter) + - FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + inset: AppSpacing.gutter, + ), child: Column( children: [ SmallcapsLabel(TermOfDayCopy.title, color: mood.accent), diff --git a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart index fabfb659..b64783ea 100644 --- a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart +++ b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart @@ -24,10 +24,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Where the design opens a drill's content, measured from the top of the -/// screen — `padding-top: 134`, clear of the bar sealed over it. -const double _designScrollPad = 134; - /// *Guess the term* — setup, the rounds, then the score. /// /// Three states of one screen rather than three routes, the shape the @@ -169,13 +165,10 @@ class _VocabGameScreenState extends ConsumerState { body: Stack( fit: StackFit.expand, children: [ - // The room the bar takes. Added here rather than inside the drill, - // whose results view is shared with the other two runs. + // Added here rather than inside the drill, whose results view is + // shared with the other two runs. Padding( - padding: FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: FloatTopbar.barRoom(context), child: pools.when( loading: () => Semantics( label: VocabCopy.loading, diff --git a/lib/features/lessons/presentation/lesson_screen.dart b/lib/features/lessons/presentation/lesson_screen.dart index 75426453..0e3e52ba 100644 --- a/lib/features/lessons/presentation/lesson_screen.dart +++ b/lib/features/lessons/presentation/lesson_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:brew_path/core/constants/app_labels.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/widgets/error_view.dart'; import 'package:brew_path/core/widgets/float_topbar.dart'; @@ -18,10 +19,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Where the design opens a run's content, measured from the top of the -/// screen — `padding-top: 134`, clear of the bar sealed over it. -const double _designScrollPad = 134; - /// Immersive single-lesson flow: plays each card, then routes to completion. class LessonScreen extends ConsumerStatefulWidget { /// Creates a [LessonScreen]. @@ -113,7 +110,7 @@ class _LessonScreenState extends ConsumerState { // have a back arrow. child: FloatTopbar.sealed( icon: AppIcon.close, - label: MaterialLocalizations.of(context).closeButtonTooltip, + label: AppLabels.close, onPressed: () => context.pop(), centre: _position(snapshot.data), // The design bookmarks a lesson **while it is being read**, @@ -191,12 +188,11 @@ class _LessonScreenState extends ConsumerState { return SafeArea( top: false, child: SingleChildScrollView( - padding: - const EdgeInsets.all(AppSpacing.lg) + - FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: FloatTopbar.runDesignScrollPad, + inset: AppSpacing.lg, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/features/mini_games/presentation/mini_game_intro_screen.dart b/lib/features/mini_games/presentation/mini_game_intro_screen.dart index 99cc938f..a3762de4 100644 --- a/lib/features/mini_games/presentation/mini_game_intro_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_intro_screen.dart @@ -103,12 +103,11 @@ class _Intro extends StatelessWidget { children: [ Expanded( child: SingleChildScrollView( - padding: - const EdgeInsets.all(AppSpacing.lg) + - FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + inset: AppSpacing.lg, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/mini_games/presentation/mini_game_player_screen.dart b/lib/features/mini_games/presentation/mini_game_player_screen.dart index e48ba5f6..86b217e5 100644 --- a/lib/features/mini_games/presentation/mini_game_player_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_player_screen.dart @@ -22,10 +22,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// Where the design opens a run's content, measured from the top of the -/// screen — `padding-top: 134`, clear of the bar sealed over it. -const double _designScrollPad = 134; - /// Runs one mini-game: its rounds in this run's order, then the results. /// /// One nonce per run — re-minted by Play again — decides the round order and @@ -108,7 +104,12 @@ class _MiniGamePlayerScreenState extends ConsumerState { body: Stack( fit: StackFit.expand, children: [ - _rounds(rounds), + // Left once for every state under it — the run, the results and the + // loader each bring their own gutter below it. + Padding( + padding: FloatTopbar.barRoom(context), + child: _rounds(rounds), + ), Positioned( top: 0, left: 0, @@ -186,17 +187,10 @@ class _MiniGamePlayerScreenState extends ConsumerState { onSolved: _onSolved, onContinue: _onContinue, ); - // Bottom only: the bar covers the top inset, and the scroll's own padding - // opens the round below it while letting it pass underneath. return SafeArea( top: false, child: SingleChildScrollView( - padding: - const EdgeInsets.all(AppSpacing.lg) + - FloatTopbar.scrollPadding( - context, - designScrollPad: _designScrollPad, - ), + padding: const EdgeInsets.all(AppSpacing.lg), // Keyed by round so each round mounts a fresh card: a latched card // must never be reused for the next statement. child: KeyedSubtree(key: ValueKey('${_nonce}_$_index'), child: card), diff --git a/test/unit/flows_seal_the_status_band_test.dart b/test/unit/flows_seal_the_status_band_test.dart new file mode 100644 index 00000000..fe9ae188 --- /dev/null +++ b/test/unit/flows_seal_the_status_band_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../support/dart_sources.dart'; + +// Which bar each full-screen flow wears (#525). A run is sealed from the first +// frame, because the card scrolls under it; a page that opens on a drawing +// shows nothing until its content moves. +void main() { + const sealed = [ + 'lib/features/lessons/presentation/lesson_screen.dart', + 'lib/features/mini_games/presentation/mini_game_player_screen.dart', + 'lib/features/dictionary/presentation/flashcards_screen.dart', + 'lib/features/dictionary/presentation/vocab/vocab_game_screen.dart', + ]; + + const onScroll = [ + 'lib/features/mini_games/presentation/mini_game_intro_screen.dart', + 'lib/features/dictionary/presentation/term_of_day_screen.dart', + ]; + + String read(String path) => withoutComments( + dartSourcesUnder( + 'lib', + ).firstWhere((file) => file.path == path).readAsStringSync(), + ); + + test('a run seals its bar from the first frame', () { + for (final path in sealed) { + expect( + read(path).contains('FloatTopbar.sealed('), + isTrue, + reason: '$path is a run: the card passes under an opaque bar', + ); + } + }); + + test('a page that opens on a drawing seals its bar on scroll', () { + for (final path in onScroll) { + final source = read(path); + expect( + source.contains('FloatTopbar.sealed('), + isFalse, + reason: '$path opens on a drawing, which a fill would sit on', + ); + expect(source.contains('FloatTopbar('), isTrue); + expect( + source.contains('isScrolled:'), + isTrue, + reason: 'its bar arrives with the content, so it needs the flag', + ); + } + }); +} diff --git a/test/widget/core/widgets/float_topbar_test.dart b/test/widget/core/widgets/float_topbar_test.dart index 06bb2e8e..ef646c68 100644 --- a/test/widget/core/widgets/float_topbar_test.dart +++ b/test/widget/core/widgets/float_topbar_test.dart @@ -150,6 +150,64 @@ void main() { }); }); + group('the room it leaves', () { + /// A device inset, so the two helpers are read over a real status bar + /// rather than over zero. + const statusBar = 59.0; + + Future roomFor( + WidgetTester tester, + EdgeInsets Function(BuildContext) read, + ) async { + late EdgeInsets room; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkRoast, + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: statusBar), + ), + child: Builder( + builder: (context) { + room = read(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + return room; + } + + testWidgets('opens a scroll where the design opens it', (tester) async { + final room = await roomFor( + tester, + (context) => FloatTopbar.scrollPadding( + context, + designScrollPad: FloatTopbar.runDesignScrollPad, + inset: 24, + ), + ); + + // The design's 134 is measured from the top of the screen, over its own + // 54px status bar; the device's inset replaces that. + expect(room.top, statusBar + (134 - 54)); + expect(room.left, 24); + expect(room.bottom, 24); + }); + + testWidgets('covers the bar, and no further, for a body with a gutter', ( + tester, + ) async { + final room = await roomFor(tester, FloatTopbar.barRoom); + + // Stops at the hairline: the body's own gutter carries the rest of the + // design's pad, and content leaves at the bar's edge rather than below + // it. + expect(room.top, statusBar + FloatTopbar.height); + }); + }); + group('the grid', () { testWidgets('carries a centre and a trailing control', (tester) async { await tester.pumpWidget( diff --git a/test/widget/mini_game_flow_test.dart b/test/widget/mini_game_flow_test.dart index d2de0924..35021392 100644 --- a/test/widget/mini_game_flow_test.dart +++ b/test/widget/mini_game_flow_test.dart @@ -1,6 +1,7 @@ import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/replay_mark.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/ghost_button.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_intro_screen.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_player_screen.dart'; @@ -565,6 +566,28 @@ void main() { }); }); + testWidgets('the intro opens on its own name, with nothing in the bar', ( + tester, + ) async { + // The design keeps a screen's name in the body below, never in the bar, + // and this one already names the game underneath (#525). + await _pump(tester); + + await tester.tap(find.text('Name the flavor notes')); + await _settle(tester); + + expect(find.byType(AppBar), findsNothing); + expect(find.text('Mini-game'), findsNothing); + expect( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(Text), + ), + findsNothing, + reason: 'the bar carries a way out and nothing else', + ); + }); + testWidgets('a game whose renderer has landed offers Play', (tester) async { // The other side of the test below, and the one that would have caught // both halves of #311: a game is only playable when the registry says so, From d7924d047b83e31b3e8714db2deb38c8c65a7400 Mon Sep 17 00:00:00 2001 From: maximsan Date: Wed, 9 Sep 2026 10:41:25 +0200 Subject: [PATCH 3/3] fix(chrome): the intro opens on its own kicker, and one frame holds every bar The word *Mini-game* left the bar with the title and landed nowhere. The design opens that body on it and files the module the game drills on the catalog row that reached it, so the kicker says what kind of thing this is. `FloatBarScaffold` replaces the `Stack` and `Positioned` each of the six screens had copied, so a flow now reads as its bar over its page. --- lib/core/widgets/float_topbar.dart | 28 ++++++++ .../presentation/flashcards_screen.dart | 72 ++++++++----------- .../presentation/term_of_day_screen.dart | 67 ++++++++--------- .../presentation/vocab/vocab_game_screen.dart | 64 +++++++---------- .../lessons/presentation/lesson_screen.dart | 46 +++++------- .../presentation/mini_game_intro_screen.dart | 47 ++++++------ .../presentation/mini_game_player_screen.dart | 52 ++++++-------- test/widget/mini_game_flow_test.dart | 9 ++- 8 files changed, 181 insertions(+), 204 deletions(-) diff --git a/lib/core/widgets/float_topbar.dart b/lib/core/widgets/float_topbar.dart index 460a2b5e..af68376d 100644 --- a/lib/core/widgets/float_topbar.dart +++ b/lib/core/widgets/float_topbar.dart @@ -164,6 +164,34 @@ class FloatTopbar extends StatelessWidget { } } +/// A full-screen flow: the page, with its bar floating over it. +/// +/// The bar is laid over the page rather than above it, so the content keeps +/// the whole screen and passes underneath. How much room it leaves for the bar +/// is the page's own business — see [FloatTopbar.scrollPadding] and +/// [FloatTopbar.barRoom]. +class FloatBarScaffold extends StatelessWidget { + /// Creates a [FloatBarScaffold]. + const FloatBarScaffold({required this.bar, required this.child, super.key}); + + /// The bar that floats over [child]. + final Widget bar; + + /// The page under it. + final Widget child; + + @override + Widget build(BuildContext context) => Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + child, + Positioned(top: 0, left: 0, right: 0, child: bar), + ], + ), + ); +} + /// The bar's painted band, reaching up under the status bar so what passes /// beneath is covered all the way to the top of the screen. class _Band extends StatelessWidget { diff --git a/lib/features/dictionary/presentation/flashcards_screen.dart b/lib/features/dictionary/presentation/flashcards_screen.dart index e9ef53c6..460bf04b 100644 --- a/lib/features/dictionary/presentation/flashcards_screen.dart +++ b/lib/features/dictionary/presentation/flashcards_screen.dart @@ -119,49 +119,39 @@ class _FlashcardsScreenState extends ConsumerState { final cards = deck.asData?.value ?? const []; final round = _roundFor(cards.length); - return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - // Added here rather than inside the views below: the results and the - // empty state are shared with the other drills, and neither should - // know what is sealed over it. - Padding( - padding: FloatTopbar.barRoom(context), - child: deck.when( - loading: () => Semantics( - label: 'Loading your deck', - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: 'Your deck could not be loaded', - child: ErrorView(message: '$error'), - ), - data: (cards) => _body(cards, round, pools.asData?.value), - ), + return FloatBarScaffold( + bar: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _close, + centre: _meter(round), + // Only worth offering when there is more than one order to deal. + trailing: cards.length > 1 && !round.isFinished + ? IconButton( + // `rematch` — "run it back" — rather than the design's own + // shuffle glyph, which the icon set does not carry. + icon: const IconMark(AppIcon.rematch), + tooltip: FlashcardsCopy.shuffle, + onPressed: () => _shuffle(cards.length), + ) + : null, + ), + // Left here rather than inside the views below: the results and the + // empty state are shared with the other drills, and neither should know + // what is sealed over it. + child: Padding( + padding: FloatTopbar.barRoom(context), + child: deck.when( + loading: () => Semantics( + label: 'Loading your deck', + child: const LoadingIndicator(), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: FloatTopbar.sealed( - icon: AppIcon.close, - label: AppLabels.close, - onPressed: _close, - centre: _meter(round), - // Only worth offering when there is more than one order to deal. - trailing: cards.length > 1 && !round.isFinished - ? IconButton( - // `rematch` — "run it back" — rather than the design's - // own shuffle glyph, which the icon set does not carry. - icon: const IconMark(AppIcon.rematch), - tooltip: FlashcardsCopy.shuffle, - onPressed: () => _shuffle(cards.length), - ) - : null, - ), + error: (error, _) => Semantics( + label: 'Your deck could not be loaded', + child: ErrorView(message: '$error'), ), - ], + data: (cards) => _body(cards, round, pools.asData?.value), + ), ), ); } diff --git a/lib/features/dictionary/presentation/term_of_day_screen.dart b/lib/features/dictionary/presentation/term_of_day_screen.dart index 1b1a2cdb..eadc1485 100644 --- a/lib/features/dictionary/presentation/term_of_day_screen.dart +++ b/lib/features/dictionary/presentation/term_of_day_screen.dart @@ -52,46 +52,35 @@ class TermOfDayScreen extends ConsumerWidget { final view = ref.watch(termOfDayViewProvider); return ScrollFlagScope( - builder: (context, {required isScrolled}) => Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - view.when( - loading: () => Semantics( - label: "Loading today's term", - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: "Today's term could not be loaded", - child: ErrorView(message: '$error'), - ), - // Nothing to offer: the pool is empty, which the banner that - // leads here would already have hidden itself for. Reachable - // only by a deep link, so it says so rather than showing an - // empty page. - data: (resolved) => resolved == null - ? const ErrorView(message: 'There is no term for today.') - : _TermOfDay(view: resolved), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: FloatTopbar( - icon: AppIcon.close, - label: AppLabels.close, - onPressed: context.pop, - isScrolled: isScrolled, - trailing: switch (view.asData?.value) { - final resolved? => SavedBookmarkButton( - savedKey: formatSavedKey(SavedKind.term, resolved.term.id), - label: resolved.term.term, - ), - null => null, - }, - ), + builder: (context, {required isScrolled}) => FloatBarScaffold( + bar: FloatTopbar( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: context.pop, + isScrolled: isScrolled, + trailing: switch (view.asData?.value) { + final resolved? => SavedBookmarkButton( + savedKey: formatSavedKey(SavedKind.term, resolved.term.id), + label: resolved.term.term, ), - ], + null => null, + }, + ), + child: view.when( + loading: () => Semantics( + label: "Loading today's term", + child: const LoadingIndicator(), + ), + error: (error, _) => Semantics( + label: "Today's term could not be loaded", + child: ErrorView(message: '$error'), + ), + // Nothing to offer: the pool is empty, which the banner that leads + // here would already have hidden itself for. Reachable only by a + // deep link, so it says so rather than showing an empty page. + data: (resolved) => resolved == null + ? const ErrorView(message: 'There is no term for today.') + : _TermOfDay(view: resolved), ), ), ); diff --git a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart index 1d3f2678..70c2663b 100644 --- a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart +++ b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart @@ -219,45 +219,35 @@ class _VocabGameScreenState extends ConsumerState { final pools = ref.watch(vocabPoolsProvider); final total = _rounds.length; - return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - // Added here rather than inside the drill, whose results view is - // shared with the other two runs. - Padding( - padding: FloatTopbar.barRoom(context), - child: pools.when( - loading: () => Semantics( - label: VocabCopy.loading, - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: VocabCopy.loadFailed, - excludeSemantics: true, - child: ErrorView(message: '$error'), - ), - data: _buildDrill, - ), + return FloatBarScaffold( + bar: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _done, + centre: _playing && _index < total + ? RoastMeter( + position: _index + 1, + total: total, + semanticsLabel: VocabCopy.progress(_index + 1, total), + ) + : null, + ), + // Left here rather than inside the drill, whose results view is shared + // with the other two runs. + child: Padding( + padding: FloatTopbar.barRoom(context), + child: pools.when( + loading: () => Semantics( + label: VocabCopy.loading, + child: const LoadingIndicator(), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: FloatTopbar.sealed( - icon: AppIcon.close, - label: AppLabels.close, - onPressed: _done, - centre: _playing && _index < total - ? RoastMeter( - position: _index + 1, - total: total, - semanticsLabel: VocabCopy.progress(_index + 1, total), - ) - : null, - ), + error: (error, _) => Semantics( + label: VocabCopy.loadFailed, + excludeSemantics: true, + child: ErrorView(message: '$error'), ), - ], + data: _buildDrill, + ), ), ); } diff --git a/lib/features/lessons/presentation/lesson_screen.dart b/lib/features/lessons/presentation/lesson_screen.dart index ae76652c..624c3823 100644 --- a/lib/features/lessons/presentation/lesson_screen.dart +++ b/lib/features/lessons/presentation/lesson_screen.dart @@ -101,36 +101,26 @@ class _LessonScreenState extends ConsumerState { // is. return FutureBuilder( future: _lesson, - builder: (context, snapshot) => Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - _buildBody(context, snapshot), - Positioned( - top: 0, - left: 0, - right: 0, - // The player is a surface you leave, not a page you came from: - // the design gives it a close mark where a pushed screen would - // have a back arrow. - child: FloatTopbar.sealed( - icon: AppIcon.close, - label: AppLabels.close, - onPressed: () => context.pop(), - centre: _position(snapshot.data), - // The design bookmarks a lesson **while it is being read**, - // not off a list afterwards. - trailing: switch (snapshot.data) { - final lesson? => SavedBookmarkButton( - savedKey: formatSavedKey(SavedKind.lesson, lesson.id), - label: lesson.title, - ), - null => null, - }, - ), + builder: (context, snapshot) => FloatBarScaffold( + // The player is a surface you leave, not a page you came from: the + // design gives it a close mark where a pushed screen would have a back + // arrow. + bar: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: () => context.pop(), + centre: _position(snapshot.data), + // The design bookmarks a lesson **while it is being read**, not off + // a list afterwards. + trailing: switch (snapshot.data) { + final lesson? => SavedBookmarkButton( + savedKey: formatSavedKey(SavedKind.lesson, lesson.id), + label: lesson.title, ), - ], + null => null, + }, ), + child: _buildBody(context, snapshot), ), ); } diff --git a/lib/features/mini_games/presentation/mini_game_intro_screen.dart b/lib/features/mini_games/presentation/mini_game_intro_screen.dart index a3762de4..0cbd9578 100644 --- a/lib/features/mini_games/presentation/mini_game_intro_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_intro_screen.dart @@ -6,6 +6,7 @@ import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/primary_button.dart'; import 'package:brew_path/core/widgets/scroll_flag_scope.dart'; +import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/features/mini_games/domain/mini_game_providers.dart'; import 'package:brew_path/features/mini_games/domain/mini_game_run.dart'; import 'package:brew_path/shared/models/content/mini_game_format.dart'; @@ -19,6 +20,9 @@ import 'package:go_router/go_router.dart'; /// `padding-top: 108`. const double _designScrollPad = 108; +/// The line the design opens the body on, where the bar used to say it. +const String _kicker = 'Mini-game'; + /// What the game is and how it is played, before any round runs. /// /// Backing out here costs nothing: no run has begun, so there is nothing to @@ -35,28 +39,18 @@ class MiniGameIntroScreen extends ConsumerWidget { final format = ref.watch(miniGameFormatProvider(formatId)); return ScrollFlagScope( - builder: (context, {required isScrolled}) => Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - _intro(format), - Positioned( - top: 0, - left: 0, - right: 0, - // No title: the screen's own name is the heading in the body - // below, which is where the design keeps it. - child: FloatTopbar( - icon: AppIcon.close, - label: AppLabels.close, - isScrolled: isScrolled, - onPressed: () => context.canPop() - ? context.pop() - : context.goNamed(AppRoutes.learn.name), - ), - ), - ], + builder: (context, {required isScrolled}) => FloatBarScaffold( + // No title: the screen's own name is the heading in the body below, + // which is where the design keeps it. + bar: FloatTopbar( + icon: AppIcon.close, + label: AppLabels.close, + isScrolled: isScrolled, + onPressed: () => context.canPop() + ? context.pop() + : context.goNamed(AppRoutes.learn.name), ), + child: _intro(format), ), ); } @@ -111,12 +105,11 @@ class _Intro extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - format.topic, - style: theme.textTheme.labelMedium?.copyWith( - color: mood.inkMute, - ), - ), + // The design opens the page on what kind of thing it is, + // and files the module the game drills on the catalog row + // that reached it. It stood in the bar until #525 took the + // bar's title away. + const SmallcapsLabel(_kicker), const SizedBox(height: AppSpacing.xxs), Semantics( header: true, diff --git a/lib/features/mini_games/presentation/mini_game_player_screen.dart b/lib/features/mini_games/presentation/mini_game_player_screen.dart index d2b8e892..980df855 100644 --- a/lib/features/mini_games/presentation/mini_game_player_screen.dart +++ b/lib/features/mini_games/presentation/mini_game_player_screen.dart @@ -100,37 +100,27 @@ class _MiniGamePlayerScreenState extends ConsumerState { Widget build(BuildContext context) { final rounds = ref.watch(miniGameRoundsProvider(widget.formatId)); - return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - // Left once for every state under it — the run, the results and the - // loader each bring their own gutter below it. - Padding( - padding: FloatTopbar.barRoom(context), - child: _rounds(rounds), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: FloatTopbar.sealed( - icon: AppIcon.close, - label: AppLabels.close, - onPressed: _done, - centre: rounds.maybeWhen( - data: (data) => data.isEmpty || _index >= data.length - ? null - : RoastMeter( - position: _index + 1, - total: data.length, - semanticsLabel: 'Round ${_index + 1} of ${data.length}', - ), - orElse: () => null, - ), - ), - ), - ], + return FloatBarScaffold( + bar: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _done, + centre: rounds.maybeWhen( + data: (data) => data.isEmpty || _index >= data.length + ? null + : RoastMeter( + position: _index + 1, + total: data.length, + semanticsLabel: 'Round ${_index + 1} of ${data.length}', + ), + orElse: () => null, + ), + ), + // Left once for every state under it — the run, the results and the + // loader each bring their own gutter below it. + child: Padding( + padding: FloatTopbar.barRoom(context), + child: _rounds(rounds), ), ); } diff --git a/test/widget/mini_game_flow_test.dart b/test/widget/mini_game_flow_test.dart index 35021392..d1e1e5f3 100644 --- a/test/widget/mini_game_flow_test.dart +++ b/test/widget/mini_game_flow_test.dart @@ -3,6 +3,7 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/replay_mark.dart'; import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/core/widgets/ghost_button.dart'; +import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_intro_screen.dart'; import 'package:brew_path/features/mini_games/presentation/mini_game_player_screen.dart'; import 'package:brew_path/features/mini_games/presentation/mini_games_catalog_widget.dart'; @@ -577,7 +578,6 @@ void main() { await _settle(tester); expect(find.byType(AppBar), findsNothing); - expect(find.text('Mini-game'), findsNothing); expect( find.descendant( of: find.byType(FloatTopbar), @@ -586,6 +586,13 @@ void main() { findsNothing, reason: 'the bar carries a way out and nothing else', ); + // The word moved into the body, where the design opens the page on it. + expect( + find.byWidgetPredicate( + (widget) => widget is SmallcapsLabel && widget.text == 'Mini-game', + ), + findsOneWidget, + ); }); testWidgets('a game whose renderer has landed offers Play', (tester) async {