diff --git a/CLAUDE.md b/CLAUDE.md index 83b20582..2df9f95d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ explanations — plus test and iOS/SPM build notes — lives in ## Code Conventions - **Imports:** always `package:brew_path/…` within `lib/`; never relative `../` imports -- **Colours:** read the mood tokens via `context.mood` (`MoodColors`, a `ThemeExtension` with a Cupping and a Dark Roast instance); never `Theme.of(context).colorScheme` — it is populated for stock Material widgets only. Everything that must **not** flip with the mood is `static const` on an `abstract final class` with no `of(context)` accessor — `ArtColors` (illustration palette), `RoastyColors` (the mascot's palette), `OverlayColors` (scrim, scrim ink, modal dim), `AppSpacing`, `AppRadii` — so mood-dependence is unrepresentable, and painters can read them with no `BuildContext`. An **overlay** is an `AppOverlay`, not a colour: it carries the design's blur radius beside its tint, and only `OverlayBarrier` renders one, so the pair cannot be split at a call site. A value that is deliberately off-token goes in the `OffTokens` register with its reason, never as a bare literal. +- **Colours:** read the mood tokens via `context.mood` (`MoodColors`, a `ThemeExtension` with a Cupping and a Dark Roast instance); never `Theme.of(context).colorScheme` — it is populated for stock Material widgets only. Everything that must **not** flip with the mood is `static const` on an `abstract final class` with no `of(context)` accessor — `ArtColors` (illustration palette), `RoastyColors` (the mascot's palette), `OverlayColors` (scrim, scrim ink, modal dim), `AppSpacing`, `AppRadii` — so mood-dependence is unrepresentable, and painters can read them with no `BuildContext`. An **overlay** is an `AppOverlay`, not a colour: it carries the design's blur radius and saturation beside its tint, and the two things that render one — a modal barrier through `OverlayBarrier`, and a top bar through `ScrolledProgress` + `AppOverlay.at` — take the whole token, so the parts cannot be split at a call site. A value that is deliberately off-token goes in the `OffTokens` register with its reason, never as a bare literal. - **Comments:** TSDoc only for complex logic or third-party integrations; skip self-evident code - **Never cite `prototype/` from `lib/`** — no file names, no line numbers. The prototype is replaced wholesale, so both go stale silently and nothing checks diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 05eaa552..a70cb39e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -59,6 +59,25 @@ You can always edit this file by hand instead — the helpers just save effort. ### Changed +- **The bar at the top of a tab gets out of the way.** It used to sit there + the whole time, printing the tab's name over the page whether you needed it + or not. Now the top of a tab is the tab: Today opens on the date, Path on + *Beginner Foundations*, Cards on *Collection* and Profile on the greeting, + each set large where the page starts. Scroll, and the bar arrives — a + blurred, tinted strip with a hairline under it, carrying the name you have + just scrolled past. Scroll back and it goes again. Saved, the Dictionary and + the Settings gear stay where they were the whole time. + + The Cards tab gets its *Collection* heading back, which it had to give up + when the bar was drawing one of its own. Today, Path and Profile open their + title below the two buttons rather than beside them, so a long date or a + long name reads all the way to its end instead of disappearing behind + them. + + The close bar on the two reward screens now blurs the same way. It was a + flat panel that appeared as you scrolled; it is the same frosted strip the + tab bar is, so the celebration underneath still shows through it. + - **Roasty's colours live in one place.** The mascot's browns, greens and face colours are one named palette pinned to the design, instead of 37 loose values across three drawings, so a retone reaches every drawing at once. diff --git a/docs/design/04-information-architecture.md b/docs/design/04-information-architecture.md index 1b8592a1..dc27c396 100644 --- a/docs/design/04-information-architecture.md +++ b/docs/design/04-information-architecture.md @@ -14,6 +14,13 @@ ## Global header (`AppHeader`) Pinned top-right: **Saved** (with count badge, lock badge if gated) and **Dictionary**. Profile variant swaps in a gear → Settings. Duel entry is present but `showDuel={!isV1}`. +**The bar itself is invisible until the tab scrolls**, and the entries are the +only part of it on show at the top of a tab: the header composes +`StickyHeaderChrome` at height 116, so the eyebrow and title below appear only +past `scrollTop > 72` ([§3](03-design-system.md)). What titles a tab at rest is +the tab's *own* large title, in its scroll — the two are a pair, and building +either one alone titles the screen twice or not at all. + Per-tab eyebrow + title (`APP_HEADER_TITLES`, `screens.jsx:713`) — user-visible copy, declared in code rather than content: | Tab | Eyebrow | Title | diff --git a/lib/app/app_header.dart b/lib/app/app_header.dart index 7b9cfc50..b3f5965a 100644 --- a/lib/app/app_header.dart +++ b/lib/app/app_header.dart @@ -1,9 +1,10 @@ import 'package:brew_path/app/current_day.dart'; +import 'package:brew_path/app/header_chrome.dart'; +import 'package:brew_path/app/header_compact_title.dart'; import 'package:brew_path/app/header_tier.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/smallcaps_label.dart'; import 'package:brew_path/features/dictionary/presentation/dictionary_home_screen.dart'; import 'package:brew_path/features/profile/domain/settings_providers.dart'; import 'package:brew_path/features/saved/domain/saved_providers.dart'; @@ -19,18 +20,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// How long the collapse takes when motion is allowed. -const _collapseDuration = Duration(milliseconds: 180); - /// The one header the four tabs share, owned by the shell. /// /// Rendered **once**, above the branch navigators, exactly as the design /// renders it once at app level beside the tab bar. The shell decides whether /// it draws at all; this decides what it says. /// -/// It consumes the status-bar inset itself, because it is the only thing here -/// that needs to: a page pushed inside a branch brings its own `AppBar`, which -/// handles its own. +/// **It floats over the tab rather than standing above it**, and at rest it +/// draws nothing but its entries: the tab's own `TabLargeTitle` is what titles +/// the screen there. Scrolled, the bar materialises and the compact title +/// slides in to replace the large one that has just gone under it — so the +/// screen is titled exactly once at every point of the scroll, which is the +/// pairing the design is built on and the reason the Cards tab had no title of +/// its own until now (#441). +/// +/// The entries stay put the whole way through. They are the only part of the +/// bar that was ever meant to be visible at the top of a tab. +/// +/// It consumes the status-bar inset itself, because the bar has to reach up +/// under the status bar to blur what passes beneath it. A tab root's content +/// starts under that inset and scrolls up through it; a page pushed inside a +/// branch brings its own `AppBar`, which handles its own. class AppHeader extends ConsumerWidget { /// Creates an [AppHeader]. const AppHeader({ @@ -56,88 +66,49 @@ class AppHeader extends ConsumerWidget { ); if (tab == null) return const SizedBox.shrink(); - final heading = Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.gutter, - AppSpacing.sm, - AppSpacing.md, - isCollapsed ? AppSpacing.xs : AppSpacing.lg, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: _Heading(tab: tab, isCollapsed: isCollapsed), - ), - // Tour stop 3 frames the pair rather than either entry: the design - // introduces Saved and the Dictionary as one place things you keep - // end up, and a frame around one button would name half of it. - TourStop( - stopKey: TourStops.header, - title: TourCopy.headerTitle, - description: TourCopy.headerBody, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in tab.actions) _ActionButton(action: action), - ], + return HeaderChrome( + height: HeaderChrome.tabHeight, + isScrolled: isCollapsed, + child: Padding( + // The design closes the bar 14 above its bottom edge, which is the + // one of the three it and the app agree on: the design sets the sides + // to 18 either way, and the bar keeps the app's own gutter on the left + // and its standard inset on the right, so the compact title lines up + // with the tab content it stands in for and the entries sit where + // every other screen's do. + padding: const EdgeInsets.fromLTRB( + AppSpacing.gutter, + 0, + AppSpacing.md, + AppSpacing.base, + ), + child: Row( + children: [ + Expanded( + child: HeaderCompactTitle( + eyebrow: tab.eyebrow, + title: tab.title, + isVisible: isCollapsed, + ), ), - ), - ], - ), - ); - - return SafeArea( - bottom: false, - // Sized by its content, not to a pair of constants: collapsing drops the - // eyebrow and the box follows. A fixed height overflows for the few - // frames after a restore, when the eyebrow is back but the box has not - // grown yet. - // - // ⚠️ **Reduced motion drops the animator, rather than giving it a zero - // duration.** `AnimatedSize` re-dirties itself inside its own - // `performLayout` when asked to finish instantly, which the framework - // asserts on — so the honest reading of "no animation" is no animator. - child: MediaQuery.disableAnimationsOf(context) - ? heading - : AnimatedSize( - duration: _collapseDuration, - curve: Curves.easeOut, - alignment: Alignment.topCenter, - child: heading, + // Tour stop 3 frames the pair rather than either entry: the design + // introduces Saved and the Dictionary as one place things you keep + // end up, and a frame around one button would name half of it. + TourStop( + stopKey: TourStops.header, + title: TourCopy.headerTitle, + description: TourCopy.headerBody, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in tab.actions) + _ActionButton(action: action), + ], + ), ), - ); - } -} - -class _Heading extends StatelessWidget { - const _Heading({required this.tab, required this.isCollapsed}); - - final TabHeader tab; - final bool isCollapsed; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (!isCollapsed) ...[ - SmallcapsLabel(tab.eyebrow), - const SizedBox(height: AppSpacing.xxs), - ], - Semantics( - header: true, - child: Text( - tab.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(color: context.mood.ink), - ), + ], ), - ], + ), ); } } diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index c64db2c0..274002c9 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -96,27 +96,35 @@ class _AppShellState extends State { // navigated away from — the host itself never disposes on a tab switch. activeBranchIndex: widget.navigationShell.currentIndex, child: Scaffold( - body: Column( + // A stack, not a column: the design's header floats **over** the tab + // and is invisible until the tab scrolls under it, so it takes no room + // of its own. The tab root leaves the room instead, in the one place + // that always opens one — `TabLargeTitle`. + body: Stack( + fit: StackFit.expand, children: [ + // Only a tab root's scrolling moves this header. A pushed page + // scrolls under its own bar, and letting it collapse a header it + // cannot see would leave the tab wrong when the learner pops back. if (showsHeader) - AppHeader( - location: location, - isCollapsed: - _collapsedByBranch[widget.navigationShell.currentIndex] ?? - false, + NotificationListener( + onNotification: _onScroll, + child: widget.navigationShell, + ) + else + widget.navigationShell, + if (showsHeader) + Positioned( + top: 0, + left: 0, + right: 0, + child: AppHeader( + location: location, + isCollapsed: + _collapsedByBranch[widget.navigationShell.currentIndex] ?? + false, + ), ), - Expanded( - // Only a tab root's scrolling moves this header. A pushed page - // scrolls under its own bar, and letting it collapse a header - // it cannot see would leave the tab wrong when the learner - // pops back. - child: showsHeader - ? NotificationListener( - onNotification: _onScroll, - child: widget.navigationShell, - ) - : widget.navigationShell, - ), ], ), bottomNavigationBar: _tabBar(context.mood), diff --git a/lib/app/header_chrome.dart b/lib/app/header_chrome.dart new file mode 100644 index 00000000..0a800d5a --- /dev/null +++ b/lib/app/header_chrome.dart @@ -0,0 +1,178 @@ +import 'package:brew_path/core/widgets/scrolled_progress.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; + +/// The one bar chrome a screen-level top bar wears. +/// +/// **Invisible at rest.** The design's header draws nothing until the page +/// under it has moved: no fill, no hairline, no blur, and — because a +/// `BackdropFilter` costs a `saveLayer` whatever its sigma — no filter in the +/// tree at all. What the learner sees at the top of a tab is the tab's own +/// large title, and the bar is only the entries floating over it. +/// +/// **Scrolled, it blends in.** The page pulled over itself at +/// `color-mix(in oklab, var(--bg) 94%, transparent)`, blurred 16px and lifted +/// back to its own warmth, with a hairline along the bottom and a short +/// gradient fading below it so type scrolling out from under the bar is never +/// seen crossing an invisible edge. +/// +/// It is a primitive rather than one screen's chrome because the design has +/// one of these and composes it twice — the tab header here, and the back bar +/// a pushed page wears (#513). Height is the caller's, so a bar and whatever +/// is laid out against it cannot drift apart. +/// +/// **The painted half is laid beside the content rather than around it**, and +/// ignores the pointer. A `DecoratedBox` claims every hit inside its +/// decoration's shape, so a bar built as one box wrapping its own contents +/// would swallow the drag that is meant to scroll the page underneath it — +/// which is exactly the gesture the design's `pointer-events: none` lets +/// through. +class HeaderChrome extends StatelessWidget { + /// Creates a [HeaderChrome] of [height], filled when [isScrolled]. + const HeaderChrome({ + required this.height, + required this.isScrolled, + required this.child, + super.key, + }); + + /// The design's tab header, measured from the top of the screen. + static const double _tabHeightWithStatusBar = 116; + + /// The status bar the design measures that height over. On a device it is + /// the top inset instead, which is why the two are subtracted rather than + /// one number being written down. + static const double _designStatusBarHeight = 54; + + /// How tall the tab header stands **below** the status bar. + static const double tabHeight = + _tabHeightWithStatusBar - _designStatusBarHeight; + + /// The gradient that fades below the bar. + static const double fadeHeight = 22; + + /// How long the bar takes to materialise when motion is allowed. + static const Duration _duration = Duration(milliseconds: 260); + + /// How tall the bar stands below the status bar. + final double height; + + /// Whether the page under the bar has scrolled far enough to need it. + final bool isScrolled; + + /// What the bar carries, laid along its bottom edge the way the design + /// aligns it. + final Widget child; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + // The bar reaches up under the status bar, because what passes beneath it + // has to be blurred all the way to the top of the screen. + final barHeight = MediaQuery.paddingOf(context).top + height; + + return ScrolledProgress( + isScrolled: isScrolled, + duration: _duration, + child: SizedBox( + height: barHeight, + child: Align(alignment: Alignment.bottomLeft, child: child), + ), + builder: (context, progress, content) => SizedBox( + height: barHeight + fadeHeight, + child: Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: _PaintedBar( + mood: mood, + progress: progress, + barHeight: barHeight, + ), + ), + ), + Positioned(top: 0, left: 0, right: 0, child: content!), + ], + ), + ), + ); + } +} + +/// The bar's painted half at [progress] of the way from invisible to filled. +class _PaintedBar extends StatelessWidget { + const _PaintedBar({ + required this.mood, + required this.progress, + required this.barHeight, + }); + + final MoodColors mood; + final double progress; + final double barHeight; + + @override + Widget build(BuildContext context) { + // The whole token, scaled: the tint, the blur and the saturation it is + // written with arrive together and fade in together — and at rest there is + // no filter at all, which is what keeps an invisible bar from paying for a + // `saveLayer`. + final headerFill = mood.headerFill.at(progress); + final bar = SizedBox( + height: barHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: headerFill.color, + border: Border( + bottom: BorderSide(color: mood.rule.withValues(alpha: progress)), + ), + ), + ), + ); + final filter = headerFill.backdropFilter; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (filter == null) + bar + else + ClipRect( + child: BackdropFilter(filter: filter, child: bar), + ), + _EdgeFade(mood: mood, progress: progress), + ], + ); + } +} + +/// The short gradient below the bar, so type leaving from under it is never +/// seen crossing an invisible edge. +class _EdgeFade extends StatelessWidget { + const _EdgeFade({required this.mood, required this.progress}); + + final MoodColors mood; + final double progress; + + @override + Widget build(BuildContext context) { + final fade = mood.headerFade; + + return SizedBox( + height: HeaderChrome.fadeHeight, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + fade.withValues(alpha: fade.a * progress), + fade.withValues(alpha: 0), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/header_compact_title.dart b/lib/app/header_compact_title.dart new file mode 100644 index 00000000..039a9ae7 --- /dev/null +++ b/lib/app/header_compact_title.dart @@ -0,0 +1,97 @@ +import 'package:brew_path/core/widgets/scrolled_progress.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; + +/// The title that slides into the bar as the screen's large title scrolls +/// away under it. +/// +/// **Absent, not merely invisible, while the page is at rest.** The design +/// fades it in from 7px below at the moment the large title has gone, so the +/// screen is titled once at every point of the scroll — and building nothing +/// at rest is what makes "once" true rather than nearly true, for a screen +/// reader and for a test as much as for the eye. +/// +/// Its eyebrow is what the tab is, its title what the tab says: `TODAY` over +/// the day, `YOUR DECK` over `Collection`. The pair is the same tab heading +/// the tab's own large title reads, so the two cannot drift apart. +class HeaderCompactTitle extends StatelessWidget { + /// Creates a [HeaderCompactTitle]. + const HeaderCompactTitle({ + required this.eyebrow, + required this.title, + required this.isVisible, + super.key, + }); + + /// The smallcaps line above — `TODAY`, `YOUR PATH`. + final String eyebrow; + + /// The line the tab is titled by. + final String title; + + /// Whether the page beneath has scrolled far enough to need it. + final bool isVisible; + + /// How far below its resting place it starts — the design's + /// `translateY(7px)`. + static const double _rise = 7; + + /// The gap between the eyebrow and the title — the design's `marginTop: 2`, + /// which is tighter than the hairline stop because the two are one stacked + /// label rather than two blocks. + static const double _stackGap = 2; + + /// How long it takes to arrive when motion is allowed. + static const Duration _duration = Duration(milliseconds: 240); + + @override + Widget build(BuildContext context) { + final mood = context.mood; + + return ScrolledProgress( + isScrolled: isVisible, + duration: _duration, + // The bar mirrors a title the screen already states, so a screen reader + // is given it once — by the screen — rather than twice by the two halves + // of one crossfade. Pointer events pass through for the same reason the + // rest of the bar lets them: `RenderParagraph` claims a hit it is drawn + // over, and the tab underneath is what the drag belongs to. + child: ExcludeSemantics( + child: IgnorePointer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + eyebrow.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.micro( + mood: mood, + tracking: AppTracking.chrome, + ), + ), + const SizedBox(height: _stackGap), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.heading(mood: mood), + ), + ], + ), + ), + ), + builder: (context, progress, stack) => progress == 0 + ? const SizedBox.shrink() + : Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, (1 - progress) * _rise), + child: stack, + ), + ), + ); + } +} diff --git a/lib/app/header_tier.dart b/lib/app/header_tier.dart index 297f8dd8..a1ff5d7c 100644 --- a/lib/app/header_tier.dart +++ b/lib/app/header_tier.dart @@ -157,10 +157,11 @@ TabHeader? tabHeaderFor( /// How far a tab scrolls before its header collapses. /// -/// Enough to be a deliberate scroll rather than a thumb resting on the screen, -/// small enough that the header is out of the way by the time the learner is -/// reading. -const double collapseThreshold = 12; +/// The design's own 72, and it is a measurement rather than a taste: the bar +/// must not raise its compact title until the tab's large title has gone under +/// it, or the screen is titled twice for the length of the transition. It is +/// also comfortably past a thumb resting on the screen. +const double collapseThreshold = 72; /// Whether a tab scrolled to [pixels] should wear a collapsed header. /// diff --git a/lib/app/tab_bar_theme.dart b/lib/app/tab_bar_theme.dart index e50dea00..60157347 100644 --- a/lib/app/tab_bar_theme.dart +++ b/lib/app/tab_bar_theme.dart @@ -1,6 +1,5 @@ import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; -import 'package:brew_path/shared/theme/off_token.dart'; import 'package:flutter/material.dart'; /// The nav family is drawn on a 24×24 grid. The marks themselves are stock @@ -34,17 +33,13 @@ Color _tabInk(MoodColors mood, Set states) => states.contains(WidgetState.selected) ? mood.accent : mood.inkMute; /// The tab label: the ladder's micro step in the control face, lettered at the -/// design's own [OffTokens.tabLabelTracking] rather than the rung's. +/// chrome tracking rather than the rung's own. /// -/// Tracking is written in em and multiplied by the size on the way out, the -/// way `AppText` does it — so the size is read back off the style rather than -/// restated here, where it could drift from the rung. -TextStyle tabLabelStyle(MoodColors mood) { - final base = AppText.micro(mood: mood, face: AppFace.control); - // Never null: every `AppText` style takes its size from the ladder. - final size = base.fontSize!; - - return base.copyWith( - letterSpacing: OffTokens.tabLabelTracking.value * size, - ); -} +/// The bar and the sticky header's compact eyebrow are the two things the +/// design letters at 0.18em, which is why the width is a named step on +/// [AppTracking] rather than this bar's private exception. +TextStyle tabLabelStyle(MoodColors mood) => AppText.micro( + mood: mood, + face: AppFace.control, + tracking: AppTracking.chrome, +); diff --git a/lib/app/tab_large_title.dart b/lib/app/tab_large_title.dart new file mode 100644 index 00000000..1b7bf42c --- /dev/null +++ b/lib/app/tab_large_title.dart @@ -0,0 +1,58 @@ +import 'package:brew_path/app/current_day.dart'; +import 'package:brew_path/app/header_tier.dart'; +import 'package:brew_path/core/constants/app_routes.dart'; +import 'package:brew_path/features/profile/domain/settings_providers.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// The large title a tab root carries at the top of its own scroll. +/// +/// The design pairs it with a header that is invisible at rest: the screen is +/// titled by the page while the page is at the top, and by the bar once the +/// page has scrolled under it. The two halves are the same pair, so this reads +/// the tab heading the shared header reads rather than restating the words — +/// which is how the Cards tab can say `Collection` again without saying it +/// twice (#396 dropped it when the bar drew a title at rest). +/// +/// **It carries the status-bar inset**, because the header no longer can: the +/// bar floats over the tab now instead of standing above it, so nothing else +/// in a tab root is above the content to make room. Every tab root opens with +/// this, which is what keeps the rule in one place. +class TabLargeTitle extends ConsumerWidget { + /// Creates the large title for the tab root at [route], opening [topGap] + /// below the status bar. + const TabLargeTitle(this.route, {this.topGap = AppSpacing.lg, super.key}); + + /// The tab root this titles. A route rather than a path string, so a tab can + /// only be named by the catalogue that defines it. + final AppRoute route; + + /// How far below the status bar the title sits. Cards opens at the design's + /// 24; the three whose titles are not fixed strings open below the header's + /// entries instead (`OffTokens.tabTitleClearOfEntries`). The tab states its + /// own, because which of the two it wants is a fact about its title. + final double topGap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = tabHeaderFor( + route.path, + today: ref.watch(currentDayProvider), + learnerName: ref.watch(learnerNameProvider).asData?.value, + ); + if (tab == null) return const SizedBox.shrink(); + + return Padding( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + topGap, + ), + child: Semantics( + header: true, + child: Text(tab.title, style: AppText.display(mood: context.mood)), + ), + ); + } +} diff --git a/lib/core/widgets/float_topbar.dart b/lib/core/widgets/float_topbar.dart index b4e3fbb8..13a5b357 100644 --- a/lib/core/widgets/float_topbar.dart +++ b/lib/core/widgets/float_topbar.dart @@ -1,5 +1,6 @@ import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/icons/icon_mark.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'; @@ -28,6 +29,12 @@ bool floatTopbarIsScrolled(double offset) => offset > floatTopbarThreshold; /// 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). class FloatTopbar extends StatelessWidget { /// Creates a [FloatTopbar]. const FloatTopbar({ @@ -60,18 +67,9 @@ class FloatTopbar extends StatelessWidget { Widget build(BuildContext context) { final mood = context.mood; - return AnimatedContainer( + return ScrolledProgress( + isScrolled: isScrolled, duration: floatTopbarFade, - curve: Curves.ease, - height: height + MediaQuery.paddingOf(context).top, - decoration: BoxDecoration( - color: isScrolled ? mood.bg : mood.bg.withValues(alpha: 0), - border: Border( - bottom: BorderSide( - color: isScrolled ? mood.rule : mood.rule.withValues(alpha: 0), - ), - ), - ), child: SafeArea( bottom: false, child: Align( @@ -90,6 +88,31 @@ class FloatTopbar extends StatelessWidget { ), ), ), + builder: (context, progress, control) { + final headerFill = mood.headerFill.at(progress); + final bar = SizedBox( + 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, + ), + ); + final filter = headerFill.backdropFilter; + + // No filter until there is a fill to go with it: an invisible bar must + // not pay for the `saveLayer` a `BackdropFilter` takes at any sigma. + if (filter == null) return bar; + return ClipRect( + child: BackdropFilter(filter: filter, child: bar), + ); + }, ); } } diff --git a/lib/core/widgets/scrolled_progress.dart b/lib/core/widgets/scrolled_progress.dart new file mode 100644 index 00000000..8282408f --- /dev/null +++ b/lib/core/widgets/scrolled_progress.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// The 0→1 a bar's chrome fades in on as the page under it scrolls. +/// +/// Every top bar in the design fades the same way — *"transition: background +/// 260ms ease, backdrop-filter 260ms ease, border-color 260ms ease"* — and +/// each one has to answer reduced motion. This is the one place that answer is +/// written: **a zero duration, not a dropped animator**, which is safe here +/// because a `TweenAnimationBuilder` given `Duration.zero` simply arrives on +/// the next frame. +/// +/// A progress rather than a flag, because what fades is a whole filter: a bar +/// that switched its blur on at the end of a tint fade would pop, and a +/// builder handed the fraction can scale both halves of the token together. +class ScrolledProgress extends StatelessWidget { + /// Creates a [ScrolledProgress]. + const ScrolledProgress({ + required this.isScrolled, + required this.duration, + required this.builder, + this.child, + super.key, + }); + + /// Whether the page under the bar has scrolled far enough to need chrome. + final bool isScrolled; + + /// How long the fade takes when motion is allowed. + final Duration duration; + + /// Draws the bar at the progress it has reached. + final ValueWidgetBuilder builder; + + /// What the bar carries, built once and handed back unchanged each frame. + final Widget? child; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: isScrolled ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : duration, + curve: Curves.ease, + builder: builder, + child: child, + ); + } +} diff --git a/lib/features/cards/presentation/cards_screen.dart b/lib/features/cards/presentation/cards_screen.dart index 67f2398a..e712cd1e 100644 --- a/lib/features/cards/presentation/cards_screen.dart +++ b/lib/features/cards/presentation/cards_screen.dart @@ -1,3 +1,5 @@ +import 'package:brew_path/app/tab_large_title.dart'; +import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/widgets/error_view.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/features/cards/domain/cards_grid.dart'; @@ -70,18 +72,25 @@ class _CardsBody extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( - // The design opens both the header block and the grid below it at 24 - // from what precedes them. + // The design opens the grid below this block at 24. No room at + // the top: `TabLargeTitle` leaves it. padding: const EdgeInsets.fromLTRB( AppSpacing.gutter, - AppSpacing.lg, + 0, AppSpacing.gutter, AppSpacing.lg, ), sliver: SliverToBoxAdapter( - child: _CollectionCount( - earned: earnedCount(list), - total: list.length, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const TabLargeTitle(AppRoutes.cards), + const SizedBox(height: AppSpacing.xs), + _CollectionCount( + earned: earnedCount(list), + total: list.length, + ), + ], ), ), ), @@ -124,13 +133,10 @@ class _CardsBody extends StatelessWidget { /// progress bar: the grid itself is the progress, and a second reading of it /// above the grid says nothing the tiles do not. /// -/// It carries no title. The tab's name is the shared header's, and the design -/// never states it twice on one screen. -/// -/// The design does give this screen a large `Collection` title — paired with a -/// header that is *invisible* until the tab scrolls. The app's header draws at -/// rest, so the pair cannot be had here yet; #441 owns that, and the title -/// comes back with it. +/// It carries no title of its own: the tab's `TabLargeTitle` above it is the +/// design's large `Collection`, and this is the count line under it. The pair +/// is the design's, and the screen still says the word once — the shared +/// header stays wordless until the grid has scrolled under it (#441). /// /// Its tracking is the design's own rather than the rung's — see /// [AppTracking.meta] for why a figure does not want the smallcaps value. diff --git a/lib/features/learn/presentation/learn_list_view.dart b/lib/features/learn/presentation/learn_list_view.dart index 823218a6..25d37ad9 100644 --- a/lib/features/learn/presentation/learn_list_view.dart +++ b/lib/features/learn/presentation/learn_list_view.dart @@ -1,4 +1,6 @@ +import 'package:brew_path/app/tab_large_title.dart'; import 'package:brew_path/core/constants/app_labels.dart'; +import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/widgets/section_header.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; import 'package:brew_path/features/challenges/domain/challenge_providers.dart'; @@ -19,6 +21,7 @@ import 'package:brew_path/features/tour/domain/tour_providers.dart'; import 'package:brew_path/features/tour/presentation/tour_stop.dart'; import 'package:brew_path/features/tour/presentation/tour_stops.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/off_token.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -29,7 +32,13 @@ class LearnListView extends ConsumerWidget { /// Creates a [LearnListView]. const LearnListView({super.key}); - static const _padding = EdgeInsets.all(AppSpacing.md); + /// No room at the top: [TabLargeTitle] leaves it. + static const _padding = EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ); static const _sectionGap = SizedBox(height: AppSpacing.lg); static const _headerGap = SizedBox(height: AppSpacing.sm); @@ -84,6 +93,13 @@ class LearnListView extends ConsumerWidget { ? const ScrollCacheExtent.viewport(_tourCacheViewports) : null, children: [ + // Below the header's entries, not beside them: the day's date is not + // a fixed string, and the long ones reach across to where they float. + TabLargeTitle( + AppRoutes.learn, + topGap: OffTokens.tabTitleClearOfEntries.value, + ), + _sectionGap, // The save beat leads the tab: someone returning after a miss is // the most fragile learner in the app, and reassurance comes // before the day's ask. Renders nothing when no save is due. diff --git a/lib/features/path/presentation/path_screen.dart b/lib/features/path/presentation/path_screen.dart index 05da2318..3a3614e1 100644 --- a/lib/features/path/presentation/path_screen.dart +++ b/lib/features/path/presentation/path_screen.dart @@ -1,3 +1,5 @@ +import 'package:brew_path/app/tab_large_title.dart'; +import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/widgets/error_view.dart'; import 'package:brew_path/core/widgets/loading_indicator.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; @@ -7,6 +9,7 @@ import 'package:brew_path/features/path/domain/path_providers.dart'; import 'package:brew_path/features/path/presentation/path_module_section.dart'; import 'package:brew_path/features/path/presentation/reference_section.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/off_token.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -18,9 +21,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// thirty-two lessons fit because each module draws at the density its state /// earns; see [PathModuleDensity]. /// -/// The screen's own header is the lesson tally alone: the course name is the -/// shell's, printed once by `AppHeader` for this tab, so repeating it here -/// would title the page twice. +/// The tab carries its own large title, as every tab root does: the shared +/// header is invisible until this list scrolls under it, and prints the same +/// course name compactly only once the large one has gone. The tally sits +/// under it, the way the design stacks the pair. class PathScreen extends ConsumerStatefulWidget { /// Creates a [PathScreen]. const PathScreen({super.key}); @@ -53,9 +57,23 @@ class _PathScreenState extends ConsumerState { loading: () => const LoadingIndicator(), error: (error, _) => ErrorView(message: '$error'), data: (list) => ListView( - padding: const EdgeInsets.all(AppSpacing.md), + // No room at the top: `TabLargeTitle` leaves it. + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), physics: const AlwaysScrollableScrollPhysics(), children: [ + TabLargeTitle( + AppRoutes.path, + topGap: OffTokens.tabTitleClearOfEntries.value, + ), + // The design sets 10 here and 8 on the Cards tab. Both are one + // stacked label rather than two blocks, so both take the hairline + // pair's stop; the 2 is not a measure either screen is built on. + const SizedBox(height: AppSpacing.xs), _CourseTally(modules: list), const SizedBox(height: AppSpacing.lg), for (var i = 0; i < list.length; i++) diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index fa8c9b0e..f410f140 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -1,3 +1,4 @@ +import 'package:brew_path/app/tab_large_title.dart'; import 'package:brew_path/core/constants/app_routes.dart'; import 'package:brew_path/core/utils/date_utils.dart'; import 'package:brew_path/core/widgets/smallcaps_label.dart'; @@ -15,6 +16,7 @@ import 'package:brew_path/features/studio/presentation/studio_door_tile.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/off_token.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -58,14 +60,22 @@ class ProfileScreen extends ConsumerWidget { body: CustomScrollView( slivers: [ SliverPadding( + // No room at the top: `TabLargeTitle` leaves it. padding: const EdgeInsets.fromLTRB( AppSpacing.gutter, - AppSpacing.xs, + 0, AppSpacing.gutter, AppSpacing.gutter, ), sliver: SliverList.list( children: [ + // Below the entries for Learn's reason: the greeting carries + // a name the learner typed, so its width is theirs to set. + TabLargeTitle( + AppRoutes.profile, + topGap: OffTokens.tabTitleClearOfEntries.value, + ), + const SizedBox(height: _headlineGap), treeStage.when( data: (stage) => TreeHeroCard( stage: stage, diff --git a/lib/shared/theme/app_overlay.dart b/lib/shared/theme/app_overlay.dart index b966cf3f..70626764 100644 --- a/lib/shared/theme/app_overlay.dart +++ b/lib/shared/theme/app_overlay.dart @@ -6,7 +6,7 @@ import 'package:flutter/painting.dart'; /// An overlay: the tint it lays over the screen and the blur it puts behind /// itself, held as **one** value. /// -/// The design registers four overlays and gives each its blur in the same +/// The design registers five overlays and gives each its filter in the same /// breath as its colour — *"Blur is part of the token's job, not a decoration: /// 5px for the modal dim, 3px for a covering wash, 8px behind a media control, /// none on the plain veil"* (`prototype/ds-content.js:1090`, restated for @@ -15,14 +15,23 @@ import 'package:flutter/painting.dart'; /// The first port transcribed all four colours and dropped all four radii /// (#379). That was possible only because they were two separate values, so a /// call site could take one and leave the other. Here they are one value, and -/// the two things that render an overlay — a modal route's barrier via -/// `OverlayBarrier`, and nothing else yet — take the whole token, so the pair -/// cannot come apart at a call site. +/// the things that render an overlay — a modal route's barrier via +/// `OverlayBarrier`, and the sticky header's bar — take the whole token, so +/// the parts cannot come apart at a call site. @immutable class AppOverlay { - /// Creates an overlay from its [color] and its [blurRadius]. - const AppOverlay({required this.color, required this.blurRadius}) - : assert(blurRadius >= 0, 'a blur radius is a length, never negative'); + /// Creates an overlay from its [color], its [blurRadius] and the + /// [saturation] it lifts what is behind to. + const AppOverlay({ + required this.color, + required this.blurRadius, + this.saturation = unsaturated, + }) : assert(blurRadius >= 0, 'a blur radius is a length, never negative'), + assert(saturation >= 0, 'saturation is a multiplier, never negative'); + + /// Saturation that changes nothing — what three of the four overlays ask + /// for, because the design gives them a plain blur. + static const double unsaturated = 1; /// The tint laid over whatever is behind. final Color color; @@ -37,6 +46,35 @@ class AppOverlay { /// constant here to get wrong: 5px in the bundle is sigma 5 on screen. final double blurRadius; + /// How far the colour behind this overlay is lifted before it is tinted. + /// + /// The design writes the sticky header's filter as `blur(16px) + /// saturate(1.3)` — one instruction, two parts — and the second part is + /// what keeps a warm page from going grey the moment sixteen pixels of + /// blur average it out. It lives here for the reason the radius does: a + /// filter written in one breath must not be takeable in halves. + /// + /// [unsaturated] leaves the colour alone, and costs nothing: the matrix is + /// built only when there is a lift to apply. + final double saturation; + + /// Whether this overlay lifts saturation at all. + bool get isSaturated => saturation != unsaturated; + + /// This overlay [progress] of the way in from nothing. + /// + /// A bar the design fades in fades the *whole* filter, not the tint with a + /// blur snapping on behind it — so the scaling belongs to the token, where + /// both halves are, rather than to each thing that fades one in. At zero it + /// is a fully transparent, unfiltered overlay, and [backdropFilter] is then + /// null: a bar on its way in pays for no `saveLayer` until it has something + /// to blur. + AppOverlay at(double progress) => AppOverlay( + color: color.withValues(alpha: color.a * progress), + blurRadius: blurRadius * progress, + saturation: unsaturated + (saturation - unsaturated) * progress, + ); + /// Whether this overlay blurs at all. /// /// False for exactly one of the four: the plain veil, which the design leaves @@ -49,19 +87,72 @@ class AppOverlay { /// Null rather than a zero-sigma filter on purpose: a `BackdropFilter` takes /// a `saveLayer` over everything behind it whatever its sigma, and an overlay /// the design gives no blur must not pay for one. - ImageFilter? get backdropFilter => isBlurred - ? ImageFilter.blur(sigmaX: blurRadius, sigmaY: blurRadius) - : null; + ImageFilter? get backdropFilter { + final blur = isBlurred + ? ImageFilter.blur(sigmaX: blurRadius, sigmaY: blurRadius) + : null; + if (!isSaturated) return blur; + + // `ColorFilter` is an `ImageFilter`, so the pair composes into the single + // filter a `BackdropFilter` takes. Outer is the saturation: the design + // blurs first and lifts what the blur averaged out, not the other way + // round. + final lift = ColorFilter.matrix(_saturationMatrix(saturation)); + return blur == null ? lift : ImageFilter.compose(outer: lift, inner: blur); + } + + /// The luminance weights CSS `saturate()` is defined against — the same + /// three SVG's `feColorMatrix type="saturate"` uses, which is where the CSS + /// filter takes its definition from. + static const double _lumaRed = 0.213; + static const double _lumaGreen = 0.715; + static const double _lumaBlue = 0.072; + + /// The 4×5 matrix that lifts saturation to [amount], leaving alpha alone. + /// + /// Each channel keeps its own weight plus what the lift adds, and loses the + /// other two channels in proportion — so at [unsaturated] this is the + /// identity, and the colour behind is untouched. + static List _saturationMatrix(double amount) { + final red = _lumaRed - _lumaRed * amount; + final green = _lumaGreen - _lumaGreen * amount; + final blue = _lumaBlue - _lumaBlue * amount; + + return [ + red + amount, + green, + blue, + 0, + 0, + red, + green + amount, + blue, + 0, + 0, + red, + green, + blue + amount, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]; + } @override bool operator ==(Object other) => other is AppOverlay && other.color == color && - other.blurRadius == blurRadius; + other.blurRadius == blurRadius && + other.saturation == saturation; @override - int get hashCode => Object.hash(color, blurRadius); + int get hashCode => Object.hash(color, blurRadius, saturation); @override - String toString() => 'AppOverlay($color, blur ${blurRadius}px)'; + String toString() => + 'AppOverlay($color, blur ${blurRadius}px, saturation $saturation)'; } diff --git a/lib/shared/theme/app_text.dart b/lib/shared/theme/app_text.dart index f284ab3f..de75f56c 100644 --- a/lib/shared/theme/app_text.dart +++ b/lib/shared/theme/app_text.dart @@ -67,9 +67,10 @@ enum AppFace { /// if ever wanted, is authored against a screen with room for it. A value /// with no call site would be vocabulary nobody speaks. /// -/// The two app values wide enough to restyle a whole rung — the tab bar's -/// 0.18em and the tap cue's 0.24em — stay in `OffTokens` instead, where an -/// exception carries its reason. +/// The line between a value here and one in `OffTokens` is how many components +/// speak it. A width the design gives to **one** component is that component's +/// exception and carries its reason there — the tap cue's 0.24em. A width more +/// than one component is set at is vocabulary, and belongs on this axis. /// /// Omitting this axis leaves a rung at its own tracking, which for [AppText]'s /// label and micro steps is the design's 0.14em smallcaps rule — `.smallcaps` @@ -109,7 +110,14 @@ enum AppTracking { /// rule so a two-word label reads as discrete: the dictionary's status chip /// (`dictionary.jsx:124`) and the collectible tile's sub-line /// (`.collect-card .cc-sub`, `index.html:712`, still unbuilt — #434). - marker(0.16); + marker(0.16), + + /// 0.18em — the app's own chrome, lettered a step wider than the pages it + /// frames. The design sets `letter-spacing: 0.18em` on the two things that + /// frame every screen: the tab bar's label, and the eyebrow in the sticky + /// header's compact title. It was the tab bar's registered exception until + /// the header's compact title turned out to be set at it too. + chrome(0.18); const AppTracking(this.em); diff --git a/lib/shared/theme/mood_colors.dart b/lib/shared/theme/mood_colors.dart index a95a3489..2968cf86 100644 --- a/lib/shared/theme/mood_colors.dart +++ b/lib/shared/theme/mood_colors.dart @@ -91,6 +91,22 @@ class MoodColors extends ThemeExtension { /// Blur behind [veilStrong] — the design's *"3px for a covering wash"*. static const veilStrongBlurRadius = 3.0; + /// Opacity of [headerFill] — the design's + /// `color-mix(in oklab, var(--bg) 94%, transparent)`. + static const headerFillOpacity = 0.94; + + /// Blur behind [headerFill] — the design's `blur(16px)`, the first half of + /// the sticky header's `backdrop-filter`. + static const headerFillBlurRadius = 16.0; + + /// The second half, `saturate(1.3)`: sixteen pixels of blur average a warm + /// page toward grey, and this is what puts the warmth back. + static const headerFillSaturation = 1.3; + + /// Opacity at the top of the fade under the bar — the design's + /// `color-mix(in oklab, var(--bg) 88%, transparent)`, fading to nothing. + static const headerFadeOpacity = 0.88; + /// Page canvas. final Color bg; @@ -217,6 +233,23 @@ class MoodColors extends ThemeExtension { blurRadius: veilStrongBlurRadius, ); + /// The sticky header's bar, once the tab beneath it has scrolled — the page + /// pulled over itself, blurred and lifted back to its own warmth. + /// + /// Derived from [bg] like the veils, so it follows the mood and keeps + /// following it mid-[lerp]. At rest the bar draws none of this: the design's + /// header is invisible until there is something for it to sit over. + AppOverlay get headerFill => AppOverlay( + color: bg.withValues(alpha: headerFillOpacity), + blurRadius: headerFillBlurRadius, + saturation: headerFillSaturation, + ); + + /// The top of the short gradient the design fades below the bar, so type + /// scrolling out from under it is never seen crossing an invisible edge. It + /// fades to transparent. + Color get headerFade => bg.withValues(alpha: headerFadeOpacity); + @override MoodColors copyWith({ Color? bg, diff --git a/lib/shared/theme/off_token.dart b/lib/shared/theme/off_token.dart index 21c0cb7b..9792f331 100644 --- a/lib/shared/theme/off_token.dart +++ b/lib/shared/theme/off_token.dart @@ -194,16 +194,22 @@ abstract final class OffTokens { 'the smallest stop reads as a gap between them.', ); - /// The tab label's letter-spacing, in em — wider than the micro rung the - /// label otherwise sits on. - static const OffToken tabLabelTracking = OffToken( - 0.18, + /// How far below the status bar a tab opens its title when the title has to + /// clear the header's entries. + static const OffToken tabTitleClearOfEntries = OffToken( + 64, reason: - 'The design letters the tab label at 0.18em where the ladder tracks ' - 'its micro rung at 0.14em (`index.html:361` against the rung table in ' - '`app_text.dart`). The bar is the only place in the shipped design ' - 'lettered this wide, so widening the rung would restyle every other ' - 'micro line to letter one bar.', + 'The design opens the Path tab at `paddingTop: 64` where Learn, Cards ' + 'and Profile open at 24. It is not a spacing stop, it is well past ' + "AppSpacing.xxl (48), and it is the design's own answer to a title " + 'that would otherwise run under the entries floating over it. Learn ' + 'and Profile take it too, which the design does not: their titles are ' + 'not fixed strings. The widest date `longDate` can make sets 350.5pt ' + 'in Fraunces at the display rung, and on a 393pt phone the entries ' + 'begin 269pt into the title — so at 24 the tail of an ordinary ' + 'Wednesday paints behind the two buttons, which the frozen `Friday, ' + "May 8` in the design never does. Cards keeps the design's 24: " + '`Collection` is a fixed string and nowhere near that wide.', ); /// The gap the intro screens set between a block and the next one. @@ -252,8 +258,10 @@ abstract final class OffTokens { '`.tap-cue` letters at 0.24em (`index.html:1111`), half again as wide ' 'as any other mono label in the design and the thing that makes it ' 'read as an instruction rather than a heading. 2.64 is that em value ' - 'at the 11px label step. Same case as tabLabelTracking: widening the ' - 'rung would reletter every micro line to style one cue.', + 'at the 11px label step. It stays an exception because the cue is the ' + 'one component set at it: a width only one thing speaks is that ' + "thing's, where a width two things share is vocabulary and belongs on " + 'AppTracking.', ); /// Every sanctioned exception, so the register can be read — and tested — as @@ -273,11 +281,11 @@ abstract final class OffTokens { keepSharpStartGap, cardsFooterPadding, cardsFooterLineGap, + tabTitleClearOfEntries, introBlockGap, introSupportGap, ghostUnderPrimaryGap, textFieldVerticalPadding, tapCueTracking, - tabLabelTracking, ]; } diff --git a/test/unit/app/tab_bar_theme_test.dart b/test/unit/app/tab_bar_theme_test.dart index d8de417e..43003746 100644 --- a/test/unit/app/tab_bar_theme_test.dart +++ b/test/unit/app/tab_bar_theme_test.dart @@ -1,7 +1,6 @@ import 'package:brew_path/app/tab_bar_theme.dart'; import 'package:brew_path/shared/theme/app_text.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; -import 'package:brew_path/shared/theme/off_token.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -83,18 +82,18 @@ void main() { expect(style.fontSize, micro.fontSize); }); - test('is lettered at the design tracking, not the rung it borrows', () { + test('is lettered at the chrome tracking, not the rung it borrows', () { final style = tabLabelStyle(MoodColors.darkRoast); final micro = AppText.micro(mood: MoodColors.darkRoast); final size = micro.fontSize!; - expect(style.letterSpacing, OffTokens.tabLabelTracking.value * size); + expect(style.letterSpacing, AppTracking.chrome.em * size); expect( style.letterSpacing, isNot(micro.letterSpacing), reason: - 'the whole reason the tracking is an off-token is that the ' - 'rung does not carry it', + 'the bar is lettered wider than the rung it sits on — that is ' + 'the whole reason the width is named at all', ); }); }); diff --git a/test/unit/overlay_pairing_guard_test.dart b/test/unit/overlay_pairing_guard_test.dart index 1074d845..882a4ce9 100644 --- a/test/unit/overlay_pairing_guard_test.dart +++ b/test/unit/overlay_pairing_guard_test.dart @@ -67,7 +67,7 @@ void main() { .map((file) => (file.path, withoutComments(file.readAsStringSync()))) .where( (source) => RegExp( - r'\b(dimModal|scrim|veil|veilStrong)\.color\b', + r'\b(dimModal|scrim|veil|veilStrong|headerFill)\.color\b', ).hasMatch(source.$2), ) .where( diff --git a/test/unit/shared/theme/off_token_test.dart b/test/unit/shared/theme/off_token_test.dart index 84707294..077de33c 100644 --- a/test/unit/shared/theme/off_token_test.dart +++ b/test/unit/shared/theme/off_token_test.dart @@ -34,12 +34,13 @@ void main() { } }); - test('holds exactly two trackings, and they are the two rung-breakers', () { + test('holds one tracking, and it is the one component that owns it', () { // Tracking is the ladder's job since #410 — `AppTracking` carries the - // design's values, and only a spacing too wide to be a rung is an - // exception. Read off the source rather than the list, because the - // register types every entry as `double` and cannot tell a tracking - // from a padding. + // design's values, and a spacing is an exception only while exactly one + // component is set at it. The tab label left when the sticky header's + // compact eyebrow turned out to be lettered at the same 0.18em (#441). + // Read off the source rather than the list, because the register types + // every entry as `double` and cannot tell a tracking from a padding. final declared = RegExp(r'OffToken (\w*Tracking) =') .allMatches( File('lib/shared/theme/off_token.dart').readAsStringSync(), @@ -47,19 +48,7 @@ void main() { .map((match) => match.group(1)) .toList(); - expect( - declared, - containsAllInOrder(['tabLabelTracking', 'tapCueTracking']), - ); - expect( - declared, - hasLength(2), - reason: - 'a third tracking here means the register is growing into a ' - 'second type system beside AppTracking, which is the thing #410 ' - 'ruled against. If the design assigns it to an app component, it ' - 'belongs on the ladder: $declared', - ); + expect(declared, ['tapCueTracking']); }); test('holds the rewarded-ad ring and the canvas it sits on', () { diff --git a/test/unit/shared/theme/overlay_blur_test.dart b/test/unit/shared/theme/overlay_blur_test.dart index b6e2e973..ee6dd943 100644 --- a/test/unit/shared/theme/overlay_blur_test.dart +++ b/test/unit/shared/theme/overlay_blur_test.dart @@ -49,6 +49,9 @@ String get _blurRuling { return ruling.single; } +/// The design's own header constants, read out of the bundle. +String _headerSource() => File('prototype/settings.jsx').readAsStringSync(); + void main() { group("the design's blur ruling", () { test('names a radius per role, and the tokens carry those radii', () { @@ -138,7 +141,7 @@ void main() { ); }); - test('is one value: colour and radius compare together', () { + test('is one value: colour, radius and saturation compare together', () { const color = Color(0xFF102030); const overlay = AppOverlay(color: color, blurRadius: 5); @@ -148,10 +151,122 @@ void main() { overlay, isNot(const AppOverlay(color: Color(0xFF302010), blurRadius: 5)), ); + expect( + overlay, + isNot( + const AppOverlay(color: color, blurRadius: 5, saturation: 1.3), + ), + ); expect( overlay.hashCode, const AppOverlay(color: color, blurRadius: 5).hashCode, ); }); + + test('asks for no saturation unless the design writes one', () { + const overlay = AppOverlay(color: Color(0xFF000000), blurRadius: 5); + + expect(overlay.saturation, AppOverlay.unsaturated); + expect(overlay.isSaturated, isFalse); + expect( + overlay.backdropFilter, + ImageFilter.blur(sigmaX: 5, sigmaY: 5), + reason: + 'an overlay the design gives no lift pays for no colour matrix — ' + 'and the identity is what makes the lift safe to fade in from, ' + 'since the bar starts every scroll at no filter at all', + ); + }); + + test('composes the lift over the blur when the design writes both', () { + const overlay = AppOverlay( + color: Color(0xFF000000), + blurRadius: 5, + saturation: 1.3, + ); + + expect(overlay.isSaturated, isTrue); + expect( + overlay.backdropFilter, + isNot(ImageFilter.blur(sigmaX: 5, sigmaY: 5)), + reason: 'the saturation half must reach the filter, not be dropped', + ); + }); + }); + + group('an overlay part of the way in', () { + const overlay = AppOverlay( + color: Color(0xFF102030), + blurRadius: 16, + saturation: 1.5, + ); + + test('at nothing is transparent, unblurred and unfiltered', () { + final none = overlay.at(0); + + expect(none.color.a, 0); + expect(none.blurRadius, 0); + expect(none.saturation, AppOverlay.unsaturated); + expect( + none.backdropFilter, + isNull, + reason: 'a bar on its way in pays for no saveLayer until it blurs', + ); + }); + + test('all the way in is the token itself', () { + expect(overlay.at(1), overlay); + }); + + test('halfway is halfway on all three, so the filter cannot pop', () { + final half = overlay.at(0.5); + + expect(half.color.a, closeTo(overlay.color.a / 2, 0.0001)); + expect(half.blurRadius, 8); + expect(half.saturation, closeTo(1.25, 0.0001)); + }); + }); + + group('the sticky header fill', () { + test('is the page at the opacity the design mixes it to', () { + final mix = RegExp( + r"HEADER_FILL = 'color-mix\(in oklab, var\(--bg\) (\d+)%", + ).firstMatch(_headerSource()); + + expect( + mix, + isNotNull, + reason: 'the bundle no longer mixes a header fill', + ); + expect( + double.parse(mix!.group(1)!) / 100, + MoodColors.headerFillOpacity, + ); + + for (final mood in [MoodColors.cupping, MoodColors.darkRoast]) { + expect( + mood.headerFill.color, + mood.bg.withValues(alpha: MoodColors.headerFillOpacity), + reason: 'the bar is the page pulled over itself, not a new colour', + ); + } + }); + + test('blurs and lifts by the two halves of the design filter', () { + final filter = RegExp( + r"HEADER_BLUR = 'blur\((\d+)px\) saturate\(([\d.]+)\)'", + ).firstMatch(_headerSource()); + + expect(filter, isNotNull, reason: 'the bundle no longer filters the bar'); + expect( + double.parse(filter!.group(1)!), + MoodColors.headerFillBlurRadius, + ); + expect( + double.parse(filter.group(2)!), + MoodColors.headerFillSaturation, + ); + expect(MoodColors.darkRoast.headerFill.isSaturated, isTrue); + }); }); } diff --git a/test/unit/shared/theme/tracking_axis_test.dart b/test/unit/shared/theme/tracking_axis_test.dart index f5539bb8..926d5f98 100644 --- a/test/unit/shared/theme/tracking_axis_test.dart +++ b/test/unit/shared/theme/tracking_axis_test.dart @@ -21,8 +21,6 @@ void main() { const sanctioned = { 'lib/shared/theme/app_text.dart': 'the ladder itself — the one place a tracking becomes pixels', - 'lib/app/tab_bar_theme.dart': - 'reads OffTokens.tabLabelTracking, a sanctioned exception at 0.18em', 'lib/core/widgets/tap_cue.dart': 'reads OffTokens.tapCueTracking, a sanctioned exception at 0.24em', 'lib/features/lessons/presentation/cards/grinder_dial_view.dart': @@ -103,6 +101,7 @@ void main() { expect(AppTracking.meta.em, 0.08); expect(AppTracking.hint.em, 0.12); expect(AppTracking.marker.em, 0.16); + expect(AppTracking.chrome.em, 0.18); }); test( diff --git a/test/widget/app_header_test.dart b/test/widget/app_header_test.dart index 38d235ae..2e2953dc 100644 --- a/test/widget/app_header_test.dart +++ b/test/widget/app_header_test.dart @@ -49,16 +49,22 @@ Widget _harness({ child: MaterialApp( home: MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), - // In a Column, as the shell places it: the header takes its natural - // height and the tab gets the rest. + // In a stack, as the shell places it: the header floats over the tab + // rather than standing above it, so it takes no room of its own. child: Scaffold( - body: Column( + body: Stack( + fit: StackFit.expand, children: [ - AppHeader( - location: location.isEmpty ? AppRoutes.learn.path : location, - isCollapsed: isCollapsed, + const SizedBox(), + Positioned( + top: 0, + left: 0, + right: 0, + child: AppHeader( + location: location.isEmpty ? AppRoutes.learn.path : location, + isCollapsed: isCollapsed, + ), ), - const Expanded(child: SizedBox()), ], ), ), @@ -71,13 +77,44 @@ double _height(WidgetTester tester) => tester.getSize(find.byType(AppHeader)).height; void main() { - testWidgets('collapsing drops the eyebrow and keeps the day', (tester) async { + testWidgets('at rest the bar says nothing — the tab titles itself', ( + tester, + ) async { await tester.pumpWidget( _harness(isCollapsed: false, disableAnimations: true), ); await tester.pumpAndSettle(); + + expect( + find.text('TODAY'), + findsNothing, + reason: 'the design keeps the bar invisible until the tab scrolls', + ); + expect(find.text('Friday, May 8'), findsNothing); + expect( + find.byTooltip(SavedScreen.title), + findsOneWidget, + reason: 'the entries are the one part of the bar always on show', + ); + }); + + testWidgets('scrolled, the compact title arrives — eyebrow and all', ( + tester, + ) async { + await tester.pumpWidget( + _harness(isCollapsed: true, disableAnimations: true), + ); + await tester.pumpAndSettle(); + expect(find.text('TODAY'), findsOneWidget); expect(find.text('Friday, May 8'), findsOneWidget); + }); + + testWidgets('the bar keeps its height either way', (tester) async { + await tester.pumpWidget( + _harness(isCollapsed: false, disableAnimations: true), + ); + await tester.pumpAndSettle(); final atRest = _height(tester); await tester.pumpWidget( @@ -85,23 +122,22 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('TODAY'), findsNothing); expect( - find.text('Friday, May 8'), - findsOneWidget, - reason: 'the title is what the learner still needs on the way down', + _height(tester), + atRest, + reason: + 'the collapse is a fill and a title arriving, not a box changing ' + "size — the tab's own content never shifts under it", ); - expect(_height(tester), lessThan(atRest)); }); - testWidgets('reduced motion settles the collapse in one frame', ( + testWidgets('reduced motion puts the title there in one frame', ( tester, ) async { await tester.pumpWidget( _harness(isCollapsed: false, disableAnimations: true), ); await tester.pumpAndSettle(); - final atRest = _height(tester); await tester.pumpWidget( _harness(isCollapsed: true, disableAnimations: true), @@ -109,18 +145,19 @@ void main() { await tester.pump(); expect( - _height(tester), - lessThan(atRest), + find.text('Friday, May 8'), + findsOneWidget, reason: 'no transition to wait out when the system asks for none', ); }); - testWidgets('with motion allowed, the collapse takes frames', (tester) async { + testWidgets('with motion allowed, the title fades in over frames', ( + tester, + ) async { await tester.pumpWidget( _harness(isCollapsed: false, disableAnimations: false), ); await tester.pumpAndSettle(); - final atRest = _height(tester); await tester.pumpWidget( _harness(isCollapsed: true, disableAnimations: false), @@ -128,14 +165,13 @@ void main() { await tester.pump(); expect( - _height(tester), - atRest, - reason: - 'the first frame has not moved yet — this is what reduced ' - 'motion is skipping', + find.text('Friday, May 8'), + findsNothing, + reason: 'it has not arrived yet — this is what reduced motion skips', ); + await tester.pumpAndSettle(); - expect(_height(tester), lessThan(atRest)); + expect(find.text('Friday, May 8'), findsOneWidget); }); group('the Saved entry', () { diff --git a/test/widget/app_shell_navigation_test.dart b/test/widget/app_shell_navigation_test.dart index d4a63784..a4fb7d61 100644 --- a/test/widget/app_shell_navigation_test.dart +++ b/test/widget/app_shell_navigation_test.dart @@ -1,6 +1,7 @@ import 'package:brew_path/app/app.dart'; import 'package:brew_path/app/app_header.dart'; import 'package:brew_path/app/app_router.dart'; +import 'package:brew_path/app/tab_large_title.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/features/dictionary/presentation/dictionary_home_screen.dart'; import 'package:brew_path/features/saved/presentation/saved_screen.dart'; @@ -27,23 +28,32 @@ void main() { testWidgets('start on Learn, under the shared header', (tester) async { await pumpWithProviders(tester, const BrewPathApp()); expect(_sharedHeader(), findsOneWidget); - expect(_headerTitled('TODAY'), findsOneWidget); + expect( + find.byType(TabLargeTitle), + findsOneWidget, + reason: 'the tab carries the screen while the bar is invisible', + ); }); - testWidgets('each names itself in the design vocabulary', (tester) async { + testWidgets('each names itself in the design vocabulary, once', ( + tester, + ) async { await pumpWithProviders(tester, const BrewPathApp()); - await tester.tap(findMark(AppIcon.route, active: false)); - await settleLoaders(tester); - expect(_headerTitled('Beginner Foundations'), findsOneWidget); - - await tester.tap(findMark(AppIcon.cards, active: false)); - await settleLoaders(tester); - expect(_headerTitled('Collection'), findsOneWidget); + // The name is the tab's own large title now, and the bar stays wordless + // until the tab scrolls under it — so `findsOneWidget` is the whole + // point of the pair, not an incidental count (#441). + for (final tab in [ + (AppIcon.route, 'Beginner Foundations'), + (AppIcon.cards, 'Collection'), + (AppIcon.leaf, 'Hello there.'), + ]) { + await tester.tap(findMark(tab.$1, active: false)); + await settleLoaders(tester); - await tester.tap(findMark(AppIcon.leaf, active: false)); - await settleLoaders(tester); - expect(_headerTitled('PROFILE'), findsOneWidget); + expect(find.text(tab.$2), findsOneWidget); + expect(_headerTitled(tab.$2), findsNothing); + } }); testWidgets('carry the Dictionary button, except Profile', (tester) async { @@ -120,7 +130,7 @@ void main() { await settleLoaders(tester); expect( - _headerTitled('Beginner Foundations'), + find.text('Beginner Foundations'), findsOneWidget, reason: 'checking what you kept must not cost you your tab', ); @@ -171,7 +181,7 @@ void main() { await tester.tap(findMark(AppIcon.route, active: false)); await settleLoaders(tester); - expect(_headerTitled('Beginner Foundations'), findsOneWidget); + expect(find.text('Beginner Foundations'), findsOneWidget); await tester.tap(findMark(AppIcon.cup, active: false)); await settleLoaders(tester); @@ -185,69 +195,112 @@ void main() { expect(_sharedHeader(), findsNothing); }); - group('collapse on scroll', () { - /// The header's height right now. Its collapse is a height change, so this - /// is the honest thing to assert — not an internal flag. - double headerHeight(WidgetTester tester) => - tester.getSize(find.byType(AppHeader)).height; + testWidgets('a tab whose title can grow opens clear of the entries', ( + tester, + ) async { + // The bar floats over the tab, so nothing lays the title and the entries + // out against each other. Three of the four titles are not fixed strings — + // the day's date, the course name, a greeting carrying a typed name — and + // at the design's 24 the widest of them paints behind the two buttons. + await pumpWithProviders(tester, const BrewPathApp()); - /// Drags the visible tab upward far enough to pass the threshold. + Future expectClear(String tab, Finder entry) async { + expect( + // The Text, not the widget: the widget's box starts at the top of the + // screen and the gap it leaves is inside it. + tester + .getRect( + find.descendant( + of: find.byType(TabLargeTitle), + matching: find.byType(Text), + ), + ) + .top, + greaterThanOrEqualTo(tester.getRect(entry).bottom), + reason: '$tab must open below its entries, not behind them', + ); + } + + await expectClear('Learn', _savedButton()); + + await tester.tap(findMark(AppIcon.route, active: false)); + await settleLoaders(tester); + await expectClear('Path', _savedButton()); + + await tester.tap(findMark(AppIcon.leaf, active: false)); + await settleLoaders(tester); + await expectClear('Profile', _settingsButton()); + }); + + group('collapse on scroll', () { + /// Drags the visible tab upward past the design's 72. Future scrollTab(WidgetTester tester) async { await tester.drag(find.byType(Scrollable).first, const Offset(0, -240)); await tester.pumpAndSettle(); } - testWidgets('scrolling a tab collapses its header', (tester) async { + testWidgets('at the top the bar is wordless and the tab is titled', ( + tester, + ) async { + await pumpWithProviders(tester, const BrewPathApp()); + + expect( + _headerTitled('TODAY'), + findsNothing, + reason: 'the design keeps the bar invisible until the tab scrolls', + ); + expect( + find.byType(TabLargeTitle), + findsOneWidget, + reason: 'the tab carries the screen at the top, as the design pairs it', + ); + }); + + testWidgets('scrolling a tab raises its compact title', (tester) async { await pumpWithProviders(tester, const BrewPathApp()); - final atRest = headerHeight(tester); - expect(_headerTitled('TODAY'), findsOneWidget); await scrollTab(tester); - expect(headerHeight(tester), lessThan(atRest)); expect( _headerTitled('TODAY'), - findsNothing, - reason: 'the eyebrow goes; the title is what the learner still needs', + findsOneWidget, + reason: 'the bar takes the screen over once the large title has gone', ); }); - testWidgets('scrolling back to the top restores it', (tester) async { + testWidgets('scrolling back to the top puts it away again', (tester) async { await pumpWithProviders(tester, const BrewPathApp()); - final atRest = headerHeight(tester); await scrollTab(tester); - expect(headerHeight(tester), lessThan(atRest)); + expect(_headerTitled('TODAY'), findsOneWidget); await tester.drag(find.byType(Scrollable).first, const Offset(0, 400)); await tester.pumpAndSettle(); - expect(headerHeight(tester), atRest); - expect(_headerTitled('TODAY'), findsOneWidget); + expect(_headerTitled('TODAY'), findsNothing); }); testWidgets('each tab keeps its own collapse across a switch', ( tester, ) async { await pumpWithProviders(tester, const BrewPathApp()); - final atRest = headerHeight(tester); await scrollTab(tester); - expect(headerHeight(tester), lessThan(atRest)); + expect(_headerTitled('TODAY'), findsOneWidget); await tester.tap(findMark(AppIcon.route, active: false)); await settleLoaders(tester); expect( - headerHeight(tester), - atRest, + _headerTitled('YOUR PATH'), + findsNothing, reason: 'Path was never scrolled, so it must not inherit the collapse', ); await tester.tap(findMark(AppIcon.cup, active: false)); await settleLoaders(tester); expect( - headerHeight(tester), - lessThan(atRest), + _headerTitled('TODAY'), + findsOneWidget, reason: 'Learn is found exactly as it was left', ); }); diff --git a/test/widget/core/widgets/float_topbar_test.dart b/test/widget/core/widgets/float_topbar_test.dart index c496643d..cd9e0344 100644 --- a/test/widget/core/widgets/float_topbar_test.dart +++ b/test/widget/core/widgets/float_topbar_test.dart @@ -1,6 +1,7 @@ import 'package:brew_path/app/app_theme.dart'; import 'package:brew_path/core/icons/app_icon.dart'; import 'package:brew_path/core/widgets/float_topbar.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,20 +10,18 @@ Widget _host(Widget child) => MaterialApp( home: Scaffold(body: child), ); -/// The bar's own fill, read off the container it animates. -Color? _fill(WidgetTester tester) => tester - .widget( - find.descendant( - of: find.byType(FloatTopbar), - matching: find.byType(AnimatedContainer), - ), - ) - .decoration - .let(); - -extension on Decoration? { - Color? let() => this is BoxDecoration ? (this! as BoxDecoration).color : null; -} +/// The bar's own fill, read off the box it paints. +Color? _fill(WidgetTester tester) => + (tester + .widget( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration) + .color; void main() { group('the threshold', () { @@ -67,8 +66,40 @@ void main() { ); await tester.pumpAndSettle(); - expect(_fill(tester), isNotNull); - expect(_fill(tester)?.a, 1); + // The header's own token, not an opaque page: the design writes this bar + // and the sticky header with the same fill, and the same filter behind + // it. + expect(_fill(tester), MoodColors.darkRoast.headerFill.color); + expect( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + }); + + testWidgets('and pays for no filter while it is invisible', (tester) async { + await tester.pumpWidget( + _host( + FloatTopbar( + icon: AppIcon.close, + label: 'Close', + onPressed: () {}, + isScrolled: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(FloatTopbar), + matching: find.byType(BackdropFilter), + ), + findsNothing, + reason: 'a BackdropFilter costs a saveLayer at any sigma', + ); }); testWidgets('carries its label for the reader and the tooltip', ( diff --git a/test/widget/core/widgets/scrolled_progress_test.dart b/test/widget/core/widgets/scrolled_progress_test.dart new file mode 100644 index 00000000..238b40c9 --- /dev/null +++ b/test/widget/core/widgets/scrolled_progress_test.dart @@ -0,0 +1,76 @@ +import 'package:brew_path/core/widgets/scrolled_progress.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The one place the reduced-motion rule for a top bar's fade is written, so +/// it is asserted here once rather than on each bar that reads it. +Widget _harness({ + required bool isScrolled, + required bool disableAnimations, +}) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: ScrolledProgress( + isScrolled: isScrolled, + duration: const Duration(milliseconds: 260), + builder: (context, progress, child) => Text( + progress.toStringAsFixed(2), + textDirection: TextDirection.ltr, + ), + ), + ), +); + +void main() { + testWidgets('starts at nothing, so a bar is invisible at rest', ( + tester, + ) async { + await tester.pumpWidget( + _harness(isScrolled: false, disableAnimations: false), + ); + await tester.pumpAndSettle(); + + expect(find.text('0.00'), findsOneWidget); + }); + + testWidgets('reduced motion arrives all the way in, in one frame', ( + tester, + ) async { + await tester.pumpWidget( + _harness(isScrolled: false, disableAnimations: true), + ); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + _harness(isScrolled: true, disableAnimations: true), + ); + await tester.pump(); + + expect( + find.text('1.00'), + findsOneWidget, + reason: + 'a zero duration is safe on a TweenAnimationBuilder — it simply ' + 'arrives on the next frame', + ); + }); + + testWidgets('with motion allowed it takes frames to get there', ( + tester, + ) async { + await tester.pumpWidget( + _harness(isScrolled: false, disableAnimations: false), + ); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + _harness(isScrolled: true, disableAnimations: false), + ); + await tester.pump(); + + expect(find.text('1.00'), findsNothing); + + await tester.pumpAndSettle(); + expect(find.text('1.00'), findsOneWidget); + }); +} diff --git a/test/widget/course_completion_flow_test.dart b/test/widget/course_completion_flow_test.dart index 8cc28601..f9563047 100644 --- a/test/widget/course_completion_flow_test.dart +++ b/test/widget/course_completion_flow_test.dart @@ -1,8 +1,8 @@ import 'package:brew_path/app/app.dart'; -import 'package:brew_path/app/app_header.dart'; import 'package:brew_path/features/companion/application/companion_providers.dart'; import 'package:brew_path/features/companion/domain/companion_lines.dart'; import 'package:brew_path/features/learn/domain/learn_providers.dart'; +import 'package:brew_path/features/learn/presentation/learn_list_view.dart'; import 'package:brew_path/features/onboarding/presentation/loading/loading_screen.dart'; import 'package:brew_path/features/progress/domain/mastery.dart'; import 'package:brew_path/features/progress/domain/progress_providers.dart'; @@ -75,7 +75,7 @@ void main() { expect(find.text('You finished Beginner Foundations'), findsOneWidget); await tester.tap(find.text('Start Keep Sharp')); - await _settleUntil(tester, find.widgetWithText(AppHeader, 'TODAY')); + await _settleUntil(tester, find.byType(LearnListView)); // The Learn bar appears while the page transition is still mid-flight // and both pages are in the tree; let the transition finish before // asserting the ending is gone. @@ -85,6 +85,6 @@ void main() { // Landed on Learn — and stayed: the ack resolved before navigation, so // the redirect no longer fires. expect(find.text('You finished Beginner Foundations'), findsNothing); - expect(find.widgetWithText(AppHeader, 'TODAY'), findsOneWidget); + expect(find.byType(LearnListView), findsOneWidget); }); } diff --git a/test/widget/features/tour/tour_replay_test.dart b/test/widget/features/tour/tour_replay_test.dart index fdb858b4..65340135 100644 --- a/test/widget/features/tour/tour_replay_test.dart +++ b/test/widget/features/tour/tour_replay_test.dart @@ -1,6 +1,6 @@ import 'package:brew_path/app/app.dart'; -import 'package:brew_path/app/app_header.dart'; import 'package:brew_path/core/icons/app_icon.dart'; +import 'package:brew_path/features/learn/presentation/learn_list_view.dart'; import 'package:brew_path/features/profile/presentation/settings/settings_copy.dart'; import 'package:brew_path/features/tour/domain/app_guide_copy.dart'; import 'package:brew_path/features/tour/domain/tour_copy.dart'; @@ -141,9 +141,9 @@ void main() { // The stops are anchored on Learn, so the row has to switch tabs — and // clear the two pushed screens it was tapped from — as well as start the - // Tour. Learn is named by the shared header's title, which is where the - // tab's identity lives now that the shell owns the chrome. - expect(find.widgetWithText(AppHeader, 'TODAY'), findsOneWidget); + // Tour. The tab's own list is what names it: the shared header is + // wordless until a tab scrolls under it (#441). + expect(find.byType(LearnListView), findsOneWidget); }); testWidgets('replay writes nothing', (tester) async { diff --git a/test/widget/header_chrome_test.dart b/test/widget/header_chrome_test.dart new file mode 100644 index 00000000..e461a7ba --- /dev/null +++ b/test/widget/header_chrome_test.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:brew_path/app/header_chrome.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The bar chrome on its own: what it paints, what it costs at rest, and the +/// fact that a finger on it still belongs to the page underneath. +Widget _harness({ + required bool isScrolled, + bool disableAnimations = true, + ScrollController? controller, +}) { + return MaterialApp( + theme: ThemeData(extensions: const [MoodColors.darkRoast]), + home: MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + ListView.builder( + controller: controller, + itemCount: 40, + itemBuilder: (context, index) => + SizedBox(height: 40, child: Text('row $index')), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: HeaderChrome( + height: HeaderChrome.tabHeight, + isScrolled: isScrolled, + child: const Text('bar'), + ), + ), + ], + ), + ), + ), + ); +} + +/// The decoration the bar paints itself with. +BoxDecoration _fill(WidgetTester tester) => + tester + .widgetList(find.byType(DecoratedBox)) + .firstWhere( + (box) => (box.decoration as BoxDecoration).border != null, + ) + .decoration + as BoxDecoration; + +void main() { + test('stands as tall as the design draws it, over the inset it measures', () { + // The design writes the tab bar's height including the status bar it is + // drawn over. On a device that band is the top inset instead, so the + // constant here is the difference — and both halves are read back out of + // the bundle rather than trusted. + final drawn = RegExp( + r'StickyHeaderChrome scrolled=\{scrolled\} height=\{(\d+)\}', + ).firstMatch(File('prototype/screens.jsx').readAsStringSync()); + final statusBar = RegExp( + r'\.status-bar\s*\{[^}]*height:\s*(\d+)px', + ).firstMatch(File('prototype/index.html').readAsStringSync()); + + expect(drawn, isNotNull, reason: 'the bundle no longer sizes its tab bar'); + expect(statusBar, isNotNull, reason: 'the bundle has no status bar to sit'); + + expect( + HeaderChrome.tabHeight, + double.parse(drawn!.group(1)!) - double.parse(statusBar!.group(1)!), + ); + }); + + testWidgets('at rest it paints nothing and pays for no filter', ( + tester, + ) async { + await tester.pumpWidget(_harness(isScrolled: false)); + await tester.pumpAndSettle(); + + expect( + _fill(tester).color?.a ?? 0, + 0, + reason: 'the design keeps the bar transparent until the page moves', + ); + expect(_fill(tester).border!.bottom.color.a, 0); + expect( + find.byType(BackdropFilter), + findsNothing, + reason: + 'a BackdropFilter costs a saveLayer at any sigma, so an invisible ' + 'bar must not mount one at all', + ); + }); + + testWidgets('scrolled, it fills, rules and blurs', (tester) async { + await tester.pumpWidget(_harness(isScrolled: true)); + await tester.pumpAndSettle(); + + expect( + _fill(tester).color, + MoodColors.darkRoast.headerFill.color, + reason: 'the bar is the page pulled over itself at the design opacity', + ); + expect(_fill(tester).border!.bottom.color, MoodColors.darkRoast.rule); + expect(find.byType(BackdropFilter), findsOneWidget); + }); + + testWidgets('reduced motion arrives filled in one frame', (tester) async { + await tester.pumpWidget(_harness(isScrolled: false)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(_harness(isScrolled: true)); + await tester.pump(); + + expect(_fill(tester).color, MoodColors.darkRoast.headerFill.color); + }); + + testWidgets('a drag that starts on the bar still scrolls the page', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _harness(isScrolled: true, controller: controller), + ); + await tester.pumpAndSettle(); + + // Started inside the bar's own box: the design gives the chrome no pointer + // events, because the page goes on underneath it. + await tester.dragFrom( + tester.getCenter(find.byType(HeaderChrome)), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0)); + }); +} diff --git a/test/widget/tab_large_title_test.dart b/test/widget/tab_large_title_test.dart new file mode 100644 index 00000000..5b0c0c9f --- /dev/null +++ b/test/widget/tab_large_title_test.dart @@ -0,0 +1,100 @@ +import 'package:brew_path/app/current_day.dart'; +import 'package:brew_path/app/header_tier.dart'; +import 'package:brew_path/app/tab_large_title.dart'; +import 'package:brew_path/core/constants/app_routes.dart'; +import 'package:brew_path/shared/theme/app_text.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'; + +import '../support/widget_harness.dart'; + +/// The day the header test freezes to, so the two agree on what Learn says. +final _today = DateTime(2026, 5, 8); + +Widget _harness(AppRoute route, {EdgeInsets padding = EdgeInsets.zero}) { + return ProviderScope( + overrides: [currentDayProvider.overrideWithValue(_today)], + child: MaterialApp( + theme: ThemeData(extensions: const [MoodColors.darkRoast]), + home: MediaQuery( + data: MediaQueryData(padding: padding), + child: Scaffold( + body: SingleChildScrollView(child: TabLargeTitle(route)), + ), + ), + ), + ); +} + +void main() { + setUp(useInMemoryDatabase); + + testWidgets('each tab root is titled by the same words the bar would use', ( + tester, + ) async { + for (final root in [ + AppRoutes.learn, + AppRoutes.path, + AppRoutes.cards, + AppRoutes.profile, + ]) { + await tester.pumpWidget(_harness(root)); + await tester.pumpAndSettle(); + + // One source for both halves of the design's pair: the large title here + // and the compact one in the bar read the same heading, so they cannot + // come to disagree about what the screen is called. + expect( + find.text(tabHeaderFor(root.path, today: _today)!.title), + findsOneWidget, + reason: '${root.path} titles itself', + ); + } + }); + + testWidgets('it is set at the display step, as a heading', (tester) async { + await tester.pumpWidget(_harness(AppRoutes.cards)); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.text('Collection')).style?.fontSize, + AppText.display().fontSize, + ); + expect( + tester.getSemantics(find.text('Collection')), + matchesSemantics(label: 'Collection', isHeader: true), + ); + }); + + testWidgets('it leaves the status bar its room, because nothing else does', ( + tester, + ) async { + const inset = 44.0; + + await tester.pumpWidget(_harness(AppRoutes.cards)); + await tester.pumpAndSettle(); + final withoutInset = tester.getTopLeft(find.text('Collection')).dy; + + await tester.pumpWidget( + _harness(AppRoutes.cards, padding: const EdgeInsets.only(top: inset)), + ); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.text('Collection')).dy, + withoutInset + inset, + reason: + 'the header floats over the tab now, so the tab is what makes room ' + 'for the status bar', + ); + }); + + testWidgets('a route that is not a tab root draws nothing', (tester) async { + await tester.pumpWidget(_harness(AppRoutes.dictionary)); + await tester.pumpAndSettle(); + + expect(find.byType(Text), findsNothing); + }); +}