Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
206 changes: 163 additions & 43 deletions lib/core/widgets/float_topbar.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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,
),
);
}
76 changes: 37 additions & 39 deletions lib/features/dictionary/presentation/flashcards_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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});
Expand Down Expand Up @@ -121,39 +119,39 @@ class _FlashcardsScreenState extends ConsumerState<FlashcardsScreen> {
final cards = deck.asData?.value ?? const <DictionaryTerm>[];
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),
),
);
}
Expand Down
Loading
Loading