Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/design/04-information-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
143 changes: 57 additions & 86 deletions lib/app/app_header.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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({
Expand All @@ -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),
),
],
),
],
),
);
}
}
Expand Down
44 changes: 26 additions & 18 deletions lib/app/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,27 +96,35 @@ class _AppShellState extends State<AppShell> {
// 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<ScrollNotification>(
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<ScrollNotification>(
onNotification: _onScroll,
child: widget.navigationShell,
)
: widget.navigationShell,
),
],
),
bottomNavigationBar: _tabBar(context.mood),
Expand Down
Loading
Loading