Skip to content

App header: one shell-owned slot over the four tabs #245

Description

@maximsan

Build for the decision at The app header the shell doesn't have (#226).

Problem Statement

Every screen in BrewPath wears a different hat. Learn, Path and Cards each build a stock AppBar carrying nothing but the tab's name; Profile builds a real collapsing header of its own; and the design's actual header — one bar over all four tabs, carrying the learner's way into the Dictionary and their Saved shelf — exists nowhere.

The cost is not cosmetic. There is no place to put anything. The Coffee Dictionary shipped with its entry point bolted onto Learn's placeholder AppBar because no shared header existed to hold it, and the Saved shelf is blocked on the same absence. Every feature that belongs in the header has to invent a home or wait.

Solution

One header, owned by the shell, sitting above the four branch navigators — the shape the design already has. It knows which tab it is showing and titles itself accordingly, carries the learner's standing entry points on the right, and collapses as that tab scrolls.

Screens the learner pushes into — a module, a term, a card — bring their own bar with a back arrow instead. Immersive flows that already escape the shell keep showing nothing at all. The result is three kinds of chrome, each with one rule about when it appears, instead of four screens each answering the question differently.

User Stories

  1. As a learner, I want one consistent header across the four tabs, so that the app feels like one product rather than four screens.
  2. As a learner, I want the header to name the tab I am on, so that I always know where I am.
  3. As a learner, I want the Learn tab's header to show today's date, so that the day's recommendation is anchored to a real day.
  4. As a learner, I want the Path tab's header to name the course, so that the thing I am progressing through is named.
  5. As a learner, I want the Cards tab's header to name my collection, so that it reads as mine rather than as a catalogue.
  6. As a learner, I want the Profile tab's header to greet me, so that the tab about me is addressed to me.
  7. As a learner, I want a Dictionary button always in reach from the tabs, so that looking a word up never costs me my place.
  8. As a learner, I want a Settings gear on Profile instead of the Dictionary and Saved buttons, so that the header offers what that tab is actually for.
  9. As a learner, I want the header to shrink as I scroll, so that content gets the screen once I have started reading.
  10. As a learner, I want each tab to remember how far it was scrolled and whether its header was collapsed, so that switching tabs and coming back does not reset me.
  11. As a learner, I want a pushed page to show its own bar with a back arrow, so that I can always get out of where I went.
  12. As a learner, I want a pushed page never to show the tab header, so that I am not offered a Dictionary button while already reading the dictionary.
  13. As a learner in a lesson, I want no header at all, so that nothing competes with the card I am working through.
  14. As a learner in a mini-game, I want no header at all, for the same reason.
  15. As a learner, I want the header to sit above the tab content and never scroll away entirely, so that my way out is always reachable.
  16. As a learner using a screen reader, I want the header's buttons labelled, so that I can reach the dictionary without seeing the icon.
  17. As a learner using a screen reader, I want the header's title announced as a heading, so that I can orient by structure.
  18. As a learner with reduced motion enabled, I want the collapse to settle without animation, so that the app respects my system setting.
  19. As a learner, I want the header's height and the content's top padding to agree, so that the first row of content is never hidden behind the bar.
  20. As a learner rotating or resizing, I want the header to keep working, so that it is not built for one screen size.
  21. As a learner who has completed nothing, I want the header to render normally, so that a fresh install is not a broken-looking app.
  22. As a developer, I want one place that decides whether a route gets the tab header, a sub-bar, or nothing, so that a new route cannot silently get the wrong chrome.
  23. As a developer, I want that decision to be a pure function over the route, so that every route's chrome can be asserted without pumping a widget.
  24. As a developer, I want per-tab titles to come from one table rather than from four screens, so that a rename happens in one place.
  25. As a developer, I want the three placeholder AppBars deleted, so that there is one implementation of tab chrome and not two.
  26. As a developer, I want ProfileHeaderDelegate reduced to page content, so that its chrome does not compete with the shared header.
  27. As a developer, I want the header to hold a declared slot for the Saved button, so that the Saved shelf can fill it without reshaping the header.
  28. As a developer, I want the collapse styling kept separate from the collapse decision, so that sub-screen bars can wear the same look later without depending on the shell.

Implementation Decisions

One header, owned by the shell

AppShell gains a header above the four branch navigators — one instance, not four. It reads the current route to decide what to show, mirroring the design's single AppHeader, which is likewise rendered once at app level as a sibling of the tab bar rather than inside any tab.

Three tiers of chrome, one rule each

Every route in the app falls into exactly one tier. This is the rule the pure function encodes:

Tier Routes Chrome Where it renders
Tab root learn, path, cards, profile the shared header inside a branch
Pushed moduleDetail, dictionary, dictionaryTerm, cardDetail its own bar, with a back arrow inside a branch
Pushed profileSettings its own bar, with a back arrow on the root navigator
Immersive lesson, lessonComplete, moduleSummary, miniGameIntro, miniGamePlay none on the root navigator

Onboarding routes (loading, welcome, onboardingGoal, onboardingBrewer, courseComplete) sit outside the shell entirely and are untouched.

⚠️ A route's tier and its navigator are two different facts, and they do not line up. profileSettings is chrome-wise a pushed page, but the router already gives it parentNavigatorKey: _rootKey — it leaves the shell exactly as the immersive routes do. So the header's suppression is only real work for the four pushed routes that stay inside a branch: moduleDetail, dictionary, dictionaryTerm, cardDetail. For everything on the root navigator — immersive and profileSettings — the shell is not on screen and the header cannot render, whatever its tier says.

Tier therefore describes the chrome a route should wear; it does not describe the mechanism that gets it there. Both are worth recording, because a route later moved between navigators changes which of the two is doing the work.

Per-tab titles come from a table

An eyebrow and a title per tab, ported from the design:

Tab Eyebrow Title
Learn TODAY today's date, long form
Path YOUR PATH Beginner Foundations
Cards YOUR DECK Collection
Profile PROFILE a greeting using the learner's name

⚠️ Learn's title is a live clock, and this is the third one. The prototype freezes it to Friday 8 May 2026 (APP_HEADER_TITLES). The app must show the real local day, and it must re-render when the day rolls over — the app already has a day-rollover watcher for exactly this class of problem, and this header should use it rather than compute the date once at build.

Profile's greeting needs the learner's stored name, with a neutral fallback when onboarding did not capture one.

The right-hand entries, and the Saved slot

Learn, Path and Cards carry the Dictionary button. Profile carries a Settings gear instead — the design's profile variant, which replaces the entries rather than adding to them.

⚠️ The Saved button is a declared slot, not shipped behaviour. The design pairs Dictionary with a Saved button carrying a count badge, but the Saved shelf has no screen#60 settled its design and its build is downstream of this ticket and the Coffee Dictionary. Shipping a button that opens nothing is worse than shipping no button. The header declares the slot and its count contract; the Saved shelf fills it when it lands.

Collapse on scroll

One listener in the shell, one collapsed flag per tab, so each branch keeps its own state across tab switches — the same reason the branches keep their own navigator stacks.

The collapse decision is in scope; the collapse look is not. Whether the bar is collapsed is shell state this ticket owns. What a collapsed bar looks like — blur, compact title, the coupled height/padding constants — is a shared style that sub-screen bars will also wear, and it remains fog on the map. This ticket ships a collapse that is correct and plain.

Header height and the content's top padding must come from one place, so a scroll container cannot drift out of agreement with the bar above it.

What gets deleted

The three stock AppBar(title: Text(…)) placeholders in Learn, Path and Cards go. ProfileHeaderDelegate keeps only page content; its chrome moves into the shared header. The Dictionary entry point currently bolted onto Learn's AppBar moves with it.

The pure seam

One function over the current route returning which tier it is in, and one table of per-tab titles. The screens and the shell read these; neither restates the rule. This follows the repo's standing rule that derivations move to a sibling pure-Dart file, and keeps AppShell.build() declarative rather than growing a route-matching cascade.

Testing Decisions

A good test here asserts what a learner would notice — pushing into a term shows a back arrow and not the Dictionary button, scrolling Learn and coming back from Path leaves Learn still collapsed — and never how the shell is wired internally. Two seams, and the first is the one that already exists.

Seam 1 — the whole-app shell test (existing, primary)

test/widget/app_shell_navigation_test.dart pumps BrewPathApp through pumpWithProviders against the real router, real shell and real screens. It is the highest seam in the repo and every acceptance criterion is observable there:

  • each tab shows the shared header with its own title and the right buttons
  • pushing into the dictionary, a module or a card shows a back arrow and never the tab header
  • a lesson and a mini-game show neither
  • scrolling a tab collapses it; switching away and back leaves that tab collapsed and the other not
  • Profile shows the Settings gear and not the Dictionary button

⚠️ This file must change as part of the work. It currently identifies tabs by find.widgetWithText(AppBar, 'Learn') and Profile by its Icons.close, both of which this ticket removes. That is the guard working, not collateral damage.

Seam 2 — the route-tier function (new, pure)

A table test over every route in AppRoutes, asserting its tier. Plain Dart, no ProviderScope, no pumpWidget. The app has nineteen routes across four branches plus immersive flows; enumerating them through widget pumps is slow and awkward, while as a pure function it is one table — and a route added later without a tier fails loudly rather than silently inheriting the wrong chrome.

The table should record the navigator alongside the tier, so the four in-branch pushed routes are visibly the ones suppression has to handle. Moving a route between navigators is then a visible edit to this table rather than a silent change in which mechanism keeps the header away.

Prior art: test/unit/app_routes_test.dart already asserts facts about the route catalogue, and the dictionary's derivation tests are the pattern for a pure module tested with no widget.

Not tested here

The collapsed appearance. This ticket asserts that the collapsed flag flips and survives a tab switch; what a collapsed bar looks like belongs to the shared collapse-styling effort.

Out of Scope

  • The collapse styling — blur, compact title, the coupled height/padding constants as a shared primitive. Sub-screen bars will wear the same look; it is one effort, and it is still fog on the map.
  • The Saved button's behaviour. The slot and its count contract are declared here; the button ships with the Saved shelf.
  • Sub-screen bars as a shared component. Pushed pages keep their own AppBar for now; unifying them into the design's SubHeader shape is the same effort as the collapse styling.
  • Onboarding chrome. Those routes sit outside the shell and are untouched.
  • The day-rollover mechanism itself — it exists; this header consumes it.

Further Notes

  • This is the second thing in a week to turn on how a four-branch StatefulShellRoute treats something that outlives a single screen. The app's second sheet arrived measured the other half and found that Flutter dismisses a modal sheet on a branch switch, on a push within a branch, and on a flat go — all three free. A header is the opposite case: it must persist across a branch switch and not persist across a push. Both facts come from the same place — the header sits above navigationShell in the shell's build, so a push inside a branch replaces the branch content beneath it while the header stays. That is why the four pushed routes that stay inside a branch need an explicit rule, and everything on the root navigator does not.
  • Landing this unblocks the Saved shelf, which was gated on exactly this and the Coffee Dictionary.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions