diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27e0a922..5dc93751 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -72,6 +72,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. + - **The cup you are fixing now reacts to your fix.** A tastefix round drew its symptoms as one line of smallcaps — `SOUR · THIN` — and did nothing when you answered. They are berry chips again, in a panel that says where the cup diff --git a/lib/core/widgets/float_topbar.dart b/lib/core/widgets/float_topbar.dart index 341618a5..af68376d 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,112 @@ 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; + /// 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. + /// + /// 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, + 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; - 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), + ), + ], ), ), + ); + + // Filled with the page's own colour, so nothing shows through and no + // filter is paid for. + if (_isSealed) { + return _Band( + height: height + MediaQuery.paddingOf(context).top, + color: mood.bg, + ruleColor: mood.rule, + 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 +163,59 @@ 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 { + 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..460bf04b 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'; @@ -27,14 +29,10 @@ import 'package:go_router/go_router.dart'; /// 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}); @@ -121,39 +119,39 @@ class _FlashcardsScreenState extends ConsumerState { final cards = deck.asData?.value ?? const []; 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), - ), - ], + 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, ), - 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'), + // 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(), + ), + error: (error, _) => Semantics( + label: 'Your deck could not be loaded', + child: ErrorView(message: '$error'), + ), + data: (cards) => _body(cards, round, pools.asData?.value), ), - 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..eadc1485 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,37 @@ 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', + return ScrollFlagScope( + builder: (context, {required isScrolled}) => FloatBarScaffold( + bar: FloatTopbar( + icon: AppIcon.close, + label: AppLabels.close, onPressed: context.pop, - ), - actions: [ - if (view.asData?.value case final resolved?) - SavedBookmarkButton( + isScrolled: isScrolled, + trailing: switch (view.asData?.value) { + final resolved? => SavedBookmarkButton( savedKey: formatSavedKey(SavedKind.term, resolved.term.id), label: resolved.term.term, ), - ], - ), - body: view.when( - loading: () => Semantics( - label: "Loading today's term", - child: const LoadingIndicator(), + null => null, + }, ), - error: (error, _) => Semantics( - label: "Today's term could not be loaded", - child: ErrorView(message: '$error'), + 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), ), - // 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,8 +111,10 @@ class _TermOfDay extends StatelessWidget { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.gutter, + padding: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + inset: AppSpacing.gutter, ), child: Column( children: [ diff --git a/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart b/lib/features/dictionary/presentation/vocab/vocab_game_screen.dart index 91189c5b..70c2663b 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'; @@ -219,14 +219,12 @@ class _VocabGameScreenState extends ConsumerState { final pools = ref.watch(vocabPoolsProvider); final total = _rounds.length; - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const IconMark(AppIcon.close), - tooltip: AppLabels.close, - onPressed: _done, - ), - title: _playing && _index < total + return FloatBarScaffold( + bar: FloatTopbar.sealed( + icon: AppIcon.close, + label: AppLabels.close, + onPressed: _done, + centre: _playing && _index < total ? RoastMeter( position: _index + 1, total: total, @@ -234,17 +232,22 @@ class _VocabGameScreenState extends ConsumerState { ) : null, ), - body: pools.when( - loading: () => Semantics( - label: VocabCopy.loading, - child: const LoadingIndicator(), - ), - error: (error, _) => Semantics( - label: VocabCopy.loadFailed, - excludeSemantics: true, - child: ErrorView(message: '$error'), + // 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(), + ), + error: (error, _) => Semantics( + label: VocabCopy.loadFailed, + excludeSemantics: true, + child: ErrorView(message: '$error'), + ), + data: _buildDrill, ), - data: _buildDrill, ), ); } diff --git a/lib/features/lessons/presentation/lesson_screen.dart b/lib/features/lessons/presentation/lesson_screen.dart index 04dfa419..624c3823 100644 --- a/lib/features/lessons/presentation/lesson_screen.dart +++ b/lib/features/lessons/presentation/lesson_screen.dart @@ -1,7 +1,8 @@ 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/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'; @@ -100,33 +101,32 @@ class _LessonScreenState extends ConsumerState { // is. 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, - ), - ], + 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, + }, ), - body: _buildBody(context, snapshot), + child: _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, and it /// is the same [RoastMeter] the mini-game player mounts. Null while the /// lesson loads — the bar keeps its close mark rather than showing a @@ -184,9 +184,16 @@ class _LessonScreenState extends ConsumerState { ), ); + // 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: 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 4b096a8d..0cbd9578 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,12 @@ +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/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'; @@ -13,6 +16,13 @@ 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; + +/// 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 @@ -28,37 +38,43 @@ 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', + return ScrollFlagScope( + 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), ), - 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,20 +92,24 @@ 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: FloatTopbar.scrollPadding( + context, + designScrollPad: _designScrollPad, + inset: AppSpacing.lg, + ), 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 e049d0fe..980df855 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/domain/card_seed.dart'; @@ -99,14 +100,12 @@ class _MiniGamePlayerScreenState extends ConsumerState { Widget build(BuildContext context) { 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( + 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( @@ -117,18 +116,27 @@ class _MiniGamePlayerScreenState extends ConsumerState { 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, + // 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), + ), + ); + } + + 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, ); } @@ -169,6 +177,7 @@ class _MiniGamePlayerScreenState extends ConsumerState { onContinue: _onContinue, ); return SafeArea( + top: false, child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.lg), // Keyed by round so each round mounts a fresh card: a latched 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/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/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..ef646c68 100644 --- a/test/widget/core/widgets/float_topbar_test.dart +++ b/test/widget/core/widgets/float_topbar_test.dart @@ -108,4 +108,150 @@ 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 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( + _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 cf92e633..7495845d 100644 --- a/test/widget/features/lessons/lesson_screen_test.dart +++ b/test/widget/features/lessons/lesson_screen_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/icon_mark.dart'; +import 'package:brew_path/core/widgets/float_topbar.dart'; import 'package:brew_path/features/lessons/domain/held_guess.dart'; import 'package:brew_path/features/lessons/presentation/cards/recall_payoff.dart'; import 'package:brew_path/features/lessons/presentation/lesson_screen.dart'; @@ -9,6 +10,7 @@ import 'package:brew_path/shared/models/content/card_parts.dart'; import 'package:brew_path/shared/models/content/content_card.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'; @@ -92,7 +94,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, ), @@ -111,13 +113,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, + ); + }); +} diff --git a/test/widget/mini_game_flow_test.dart b/test/widget/mini_game_flow_test.dart index d2de0924..d1e1e5f3 100644 --- a/test/widget/mini_game_flow_test.dart +++ b/test/widget/mini_game_flow_test.dart @@ -1,7 +1,9 @@ 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/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'; @@ -565,6 +567,34 @@ 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.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(Text), + ), + 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 { // 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,