feat(ui): on a phone, the floating button sits just above the navigation bar, its screen's fade under it
Each tab's screen paints the bar's fade beneath its own button (FrameFade as its bottomNavigationBar, extendBody), so the button stands 16 pixels above the bar, as Material's, instead of above the fade; the frame keeps its own fade for screens without one, and gets it back while a page is pushed over the screen. Whats-New: On a phone, the + button now sits just above the navigation bar. Whats-New-De: Auf dem Handy sitzt der +-Knopf jetzt direkt über der Navigationsleiste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f47f6b23b5
commit
c5eceda383
10 changed files with 466 additions and 122 deletions
|
|
@ -35,7 +35,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
|
||||
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
||||
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card; its top corners rounded, 20 dp, a tap on another tab sending a wave over it in the colour of the screen in view (`ScreenTint.color`: cobalt on Learn's tabs, a studyset's own inside it; the frame's `accent`, Learn's purple, only before a screen claims one; Learn's tabs wash in cobalt, so its bar is cobalt there, not its icon's purple, by the developer's choice, 8 Nov 2026), the tab chosen in that colour made legible on the bar (`readableTone`, 4.5:1 in both themes), without Material's ripple or the indicator growing, the indicator kept, on the bar's own neutral surface: decided by the developer on 8 Nov 2026 after trying the variants on the phone, see ADR 0030's amendment of D60), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and floating buttons lifted by `FlushNavigationBar.buttonLiftOf` (the same without the system's bar, which a Scaffold keeps its button clear of itself); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card; its top corners rounded, 20 dp, a tap on another tab sending a wave over it in the colour of the screen in view (`ScreenTint.color`: cobalt on Learn's tabs, a studyset's own inside it; the frame's `accent`, Learn's purple, only before a screen claims one; Learn's tabs wash in cobalt, so its bar is cobalt there, not its icon's purple, by the developer's choice, 8 Nov 2026), the tab chosen in that colour made legible on the bar (`readableTone`, 4.5:1 in both themes), without Material's ripple or the indicator growing, the indicator kept, on the bar's own neutral surface: decided by the developer on 8 Nov 2026 after trying the variants on the phone, see ADR 0030's amendment of D60), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and a screen with a floating button painting the fade itself beneath it (`FrameFade` in its Scaffold's bottom slot, with `extendBody`, the button placed by `FrameFade.buttonLocation`: 16 dp above the bar, over the fade, as Material places it; the frame paints no fade of its own while such a screen is in view, a page pushed over it ending that, a menu or dialog not; decided by the developer on 9 Nov 2026, the button having stood 48 dp above the bar, clear of a fade the frame painted over it); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById`: a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||
- **The look is one theme, every Halkyon app's**: the theme, its fonts, the window sizes and the shared widgets come from `halkyon_ui` (`packages/halkyon_ui`, decided 2026-10-18). Change them there, not here. `appTheme(brightness, extensions:)` with hand-written light and dark colour schemes around the brand's cobalt and copper, which Learn calls as `learnTheme` (`core/theme/`) with its own colours beside; `Theme.of(context).extras` (`ThemeExtras`) for the states Material has no role for, and `Theme.of(context).learnColors` (`LearnColors`) for Learn's own (the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), 16 in from the sides and 8 above the bar's fade, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen and Settings (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. halkyon_ui's `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface, and Learn's `learn_theme_test.dart` a starred star to 3:1 on the cards. **Fonts are bundled, never fetched** (halkyon_ui's `app_fonts.dart`; the CJK fonts are the app's own, `assets/fonts/ATTRIBUTION.md`, their licence registered by `core/theme/cjk_fonts.dart`, under the families halkyon_ui's fallback names): Noto Sans, from halkyon_ui, is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets come from halkyon_ui (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` and `BrandWordmark` ("Halkyon" over an app's word), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), `Shimmer` and `SkeletonBox` (what a list shows while it loads in place of a spinner, the Library's cards since 1 Nov 2026), and the sheets below), with texts of their own (`HalkyonUiLocalizations`); Learn's own are in `core/widget/` (`ConflictVersions`, the versions of a conflict each editor shows, with Keep, its save status beside it `SaveStatusLabel` in `core/editor/`; `ContentCountsLine`; `AppLaunchScreen`, what shows while the app opens: the kingfisher, "Halkyon" over "Learn", whose wordmark the first start shows too).
|
||||
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
|
||||
|
|
|
|||
|
|
@ -105,21 +105,20 @@ class _HomeState() extends State<_Home> {
|
|||
// The keyboard covers the screen rather than shrinking it while the
|
||||
// assistant's field is open, which lifts itself above it (D69).
|
||||
resizeToAvoidBottomInset: !composerOpen(context),
|
||||
// The sections scroll on beneath the Ask field and the navigation bar.
|
||||
// No field, no slot: an empty one would take the room the body keeps
|
||||
// for the navigation bar.
|
||||
// The sections scroll on beneath the Ask field, the fade the screen
|
||||
// paints (FrameFade, in the bar's slot) and the navigation bar.
|
||||
extendBody: true,
|
||||
// Folded into its button at the lower left, as in the Library (decided
|
||||
// 2026-11-01), clear of the navigation bar and its fade at the foot.
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
|
||||
// 2026-11-01), over the fade above the navigation bar, which the screen
|
||||
// paints beneath it.
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(
|
||||
context,
|
||||
FloatingActionButtonLocation.startFloat,
|
||||
),
|
||||
floatingActionButton: _arranging || !assistantOn(context)
|
||||
? null
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: const AssistantButton(topic: AssistantTopicLearning()),
|
||||
),
|
||||
: const AssistantButton(topic: AssistantTopicLearning()),
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
appBar: _arranging
|
||||
? AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
|
|
|
|||
|
|
@ -261,20 +261,19 @@ class _LibraryState() extends State<_Library> {
|
|||
),
|
||||
],
|
||||
),
|
||||
floatingActionButtonLocation: switch ((asks, create)) {
|
||||
(true, _?) => FloatingActionButtonLocation.centerFloat,
|
||||
(true, null) => FloatingActionButtonLocation.startFloat,
|
||||
(false, _) => FloatingActionButtonLocation.endFloat,
|
||||
},
|
||||
// Clear of the navigation bar and its fade at the foot on a phone.
|
||||
floatingActionButton: fab == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: fab,
|
||||
),
|
||||
// Over the fade above the navigation bar on a phone, which the
|
||||
// screen paints beneath it, its cards running on beneath both.
|
||||
extendBody: true,
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(
|
||||
context,
|
||||
switch ((asks, create)) {
|
||||
(true, _?) => FloatingActionButtonLocation.centerFloat,
|
||||
(true, null) => FloatingActionButtonLocation.startFloat,
|
||||
(false, _) => FloatingActionButtonLocation.endFloat,
|
||||
},
|
||||
),
|
||||
floatingActionButton: fab,
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -261,10 +261,12 @@ void main() {
|
|||
final pill = tester.getRect(find.byKey(FlushNavigationBar.fadeKey));
|
||||
expect(tester.getBottomLeft(find.byType(LibraryScreen)).dy, 900);
|
||||
// Before scrolling, which slides it away.
|
||||
// Over the fade, which the Library paints beneath it, 16 above the bar
|
||||
// (the developer's review, 9 Nov 2026).
|
||||
expect(
|
||||
tester.getRect(find.byTooltip('Create studyset')).bottom,
|
||||
lessThan(pill.top),
|
||||
reason: "the Library's button stands clear of the bar",
|
||||
tester.getRect(find.byKey(FlushNavigationBar.barKey)).top - 16,
|
||||
reason: "the Library's button stands 16 above the bar",
|
||||
);
|
||||
await tester.fling(
|
||||
find.byType(CustomScrollView),
|
||||
|
|
|
|||
|
|
@ -31,16 +31,13 @@ class const TabScreen({
|
|||
actions: [...actions, const SizedBox(width: 4)],
|
||||
),
|
||||
body: body,
|
||||
// Clear of the navigation bar and its fade at the foot on a phone.
|
||||
floatingActionButton: switch (floatingActionButton) {
|
||||
final button? => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: button,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
// On a phone the button stands over the fade above the navigation
|
||||
// bar, which the screen paints beneath it, the cards running on
|
||||
// beneath both (the developer's review, 9 Nov 2026).
|
||||
extendBody: true,
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,16 +63,7 @@ class const TabScreen({
|
|||
// back as they scroll up, as Learn's (the developer's review, 7 Nov
|
||||
// 2026).
|
||||
child: ScrollHidingFab(
|
||||
fab: switch (floatingActionButton) {
|
||||
// Clear of the navigation bar and its fade at the foot on a phone.
|
||||
final button? when !choosing => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: button,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
fab: choosing ? null : floatingActionButton,
|
||||
builder: (context, fab) => Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
// Quick add's bar and the keyboard float over the tasks, which
|
||||
|
|
@ -118,9 +109,15 @@ class const TabScreen({
|
|||
],
|
||||
),
|
||||
body: body,
|
||||
// On a phone the button stands over the fade above the
|
||||
// navigation bar, which the screen paints beneath it, the tasks
|
||||
// running on beneath both (the developer's review, 9 Nov 2026);
|
||||
// while tasks are chosen, the bar of what to do with them.
|
||||
extendBody: !choosing,
|
||||
bottomNavigationBar: choosing
|
||||
? TaskSelectionBar(selection: selection)
|
||||
: null,
|
||||
: const FrameFade(),
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
floatingActionButton: fab,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -34,16 +34,13 @@ class const TabScreen({
|
|||
bottom: bottom,
|
||||
),
|
||||
body: body,
|
||||
// Clear of the navigation bar and its fade at the foot on a phone.
|
||||
floatingActionButton: switch (floatingActionButton) {
|
||||
final button? => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: button,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
// On a phone the button stands over the fade above the navigation
|
||||
// bar, which the screen paints beneath it, the content running on
|
||||
// beneath both (the developer's review, 9 Nov 2026).
|
||||
extendBody: true,
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
floatingActionButton: floatingActionButton,
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class const FlushNavigationBar({
|
|||
/// What stands above the bar while it matters (Train's recording under
|
||||
/// way), on [background], below the fade; none while null.
|
||||
final Widget? status,
|
||||
|
||||
/// Whether this paints the fade; where not, the screen in view paints it
|
||||
/// itself beneath its floating button (`FrameFade`), and its room stays.
|
||||
final bool fades = true,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// How high the fade above the bar is.
|
||||
|
|
@ -59,9 +63,27 @@ class const FlushNavigationBar({
|
|||
NavigationBarTheme.of(context).backgroundColor ??
|
||||
Theme.of(context).colorScheme.surfaceContainer;
|
||||
|
||||
/// The fade above the bar, for tests to find.
|
||||
/// The fade above the bar, for tests to find, wherever it is painted.
|
||||
static const fadeKey = Key('flush-navigation-bar-fade');
|
||||
|
||||
/// The fade, [fadeHeight] high, from see-through into [background]; taps
|
||||
/// pass through it.
|
||||
static Widget fadeInto(Color background) => IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
key: fadeKey,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
// From the background itself, see-through: transparent black
|
||||
// would grey the fade on its way.
|
||||
colors: [background.withValues(alpha: 0), background],
|
||||
),
|
||||
),
|
||||
child: const SizedBox(height: fadeHeight),
|
||||
),
|
||||
);
|
||||
|
||||
/// The bar's clip, which rounds its corners, for tests to find.
|
||||
static const barKey = Key('flush-navigation-bar');
|
||||
|
||||
|
|
@ -76,15 +98,6 @@ class const FlushNavigationBar({
|
|||
static double coverOf(BuildContext context) =>
|
||||
MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
/// How far a screen's floating button rises to stand clear of the
|
||||
/// navigation bar and its fade: [coverOf] without the system's own bar,
|
||||
/// which a Scaffold keeps its floating button clear of by itself.
|
||||
static double buttonLiftOf(BuildContext context) => math.max(
|
||||
0,
|
||||
MediaQuery.paddingOf(context).bottom -
|
||||
MediaQuery.viewPaddingOf(context).bottom,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = this.status;
|
||||
|
|
@ -92,21 +105,7 @@ class const FlushNavigationBar({
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
key: fadeKey,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
// From the background itself, see-through: transparent black
|
||||
// would grey the fade on its way.
|
||||
colors: [background.withValues(alpha: 0), background],
|
||||
),
|
||||
),
|
||||
child: const SizedBox(height: fadeHeight),
|
||||
),
|
||||
),
|
||||
if (fades) fadeInto(background) else const SizedBox(height: fadeHeight),
|
||||
if (status != null)
|
||||
ColoredBox(
|
||||
color: background,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_ui/src/layout/window_size.dart';
|
||||
import 'package:halkyon_ui/src/theme/readable_tone.dart';
|
||||
import 'package:halkyon_ui/src/widget/flush_navigation_bar.dart';
|
||||
import 'package:halkyon_ui/src/widget/screen_tint.dart';
|
||||
import 'package:halkyon_ui/src/widget/tab_visibility.dart';
|
||||
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -85,6 +87,9 @@ class const NavigationFrame({
|
|||
class _NavigationFrameState() extends State<NavigationFrame> {
|
||||
final _tint = ScreenTint();
|
||||
|
||||
/// The screens in view painting the fade themselves ([FrameFade]).
|
||||
final _fadeClaims = _FadeClaims();
|
||||
|
||||
/// The screens' own messenger below medium windows: their snack bars
|
||||
/// show on the screens' own scaffolds, which lift them above their
|
||||
/// floating buttons, as Material places them; on the frame's they lay
|
||||
|
|
@ -100,6 +105,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
@override
|
||||
void dispose() {
|
||||
_tint.dispose();
|
||||
_fadeClaims.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +176,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
// the colour of the screen in view (the app's until one claims its
|
||||
// own), changing over 300 ms.
|
||||
child: ListenableBuilder(
|
||||
listenable: _tint,
|
||||
listenable: Listenable.merge([_tint, _fadeClaims]),
|
||||
builder: (context, _) {
|
||||
final claimed = _tint.background != null;
|
||||
final target = claimed ? _tint.color : widget.accent;
|
||||
|
|
@ -234,7 +240,14 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
// it keeps its size and lifts the field instead (Halkyon Learn's ADR
|
||||
// 0021, D69).
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: ScaffoldMessenger(key: _phoneMessenger, child: widget.child),
|
||||
body: _FrameFoot(
|
||||
claims: _fadeClaims,
|
||||
showsBar: !widget.hidesNavigationBar,
|
||||
child: _FrameFadeColour(
|
||||
background: background,
|
||||
child: ScaffoldMessenger(key: _phoneMessenger, child: widget.child),
|
||||
),
|
||||
),
|
||||
// Without the bar, a strip as high as the system's own bar, in the
|
||||
// screen's background, keeps every screen inside clear of it.
|
||||
bottomNavigationBar: widget.hidesNavigationBar
|
||||
|
|
@ -255,6 +268,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
destinationCount: destinations.length,
|
||||
wave: _wave,
|
||||
status: widget.status,
|
||||
fades: !_fadeClaims.any,
|
||||
bar: _Chosen(
|
||||
// The colour as text on the bar and on its indicator (WCAG
|
||||
// AA), a person's light colours darkened in light mode, dark
|
||||
|
|
@ -333,3 +347,182 @@ class const _RailIcon({
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The fade above the frame's bar painted by the screen itself, beneath its
|
||||
/// floating button, so that the button stands over the fade, 16 above the
|
||||
/// bar, as Material places it above a bar (the developer's review, 9 Nov
|
||||
/// 2026): the frame paints the fade over its screens, their buttons too, so
|
||||
/// a button had to keep above it, 48 above the bar. A screen's Scaffold takes
|
||||
/// it as its `bottomNavigationBar`, with `extendBody` and its button placed
|
||||
/// by [buttonLocation]: as high as the foot the frame covers (the fade, the
|
||||
/// bar and what stands above it, the system's bar), so the content keeps its
|
||||
/// room, the fade at its top; nothing from medium windows on, or where the
|
||||
/// bar steps aside. While its screen is in view (its tab showing, no page
|
||||
/// over it; a menu or dialog leaves it in view) the frame paints no fade of
|
||||
/// its own.
|
||||
class const FrameFade({super.key}) extends StatefulWidget {
|
||||
/// Where a screen with a [FrameFade] places its floating button: [base],
|
||||
/// lowered over the fade to 16 above the bar where the frame's bar shows.
|
||||
static FloatingActionButtonLocation buttonLocation(
|
||||
BuildContext context, [
|
||||
FloatingActionButtonLocation base = FloatingActionButtonLocation.endFloat,
|
||||
]) {
|
||||
final foot = context.dependOnInheritedWidgetOfExactType<_FrameFoot>();
|
||||
return foot != null && foot.showsBar ? _OverTheFade(base) : base;
|
||||
}
|
||||
|
||||
@override
|
||||
State<FrameFade> createState() => _FrameFadeState();
|
||||
}
|
||||
|
||||
/// [base] lowered by the fade's height.
|
||||
final class const _OverTheFade(final FloatingActionButtonLocation base)
|
||||
extends FloatingActionButtonLocation {
|
||||
@override
|
||||
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) => base
|
||||
.getOffset(scaffoldGeometry)
|
||||
.translate(0, FlushNavigationBar.fadeHeight);
|
||||
|
||||
// Equal for the same base, so that a screen built again doesn't move its
|
||||
// button as if to another place.
|
||||
@override
|
||||
bool operator ==(Object other) => other is _OverTheFade && other.base == base;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(_OverTheFade, base);
|
||||
}
|
||||
|
||||
class _FrameFadeState() extends State<FrameFade> {
|
||||
_FrameFoot? _foot;
|
||||
|
||||
/// The foot's height as last seen without the keyboard, kept while it is
|
||||
/// up: beneath it the Scaffold gives this slot no padding, and the
|
||||
/// screen's button and content would move, behind a keyboard covering
|
||||
/// the foot anyway.
|
||||
double? _height;
|
||||
Animation<double>? _covering;
|
||||
bool _visible = true;
|
||||
|
||||
/// The claims this told last that it paints the fade, if it does.
|
||||
_FadeClaims? _claimed;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final foot = context.dependOnInheritedWidgetOfExactType<_FrameFoot>();
|
||||
_foot = foot != null && foot.showsBar ? foot : null;
|
||||
// A page pushed over the route drives its secondary animation; a menu
|
||||
// or a dialog doesn't, leaving the screen in view.
|
||||
final covering = ModalRoute.of(context)?.secondaryAnimation;
|
||||
if (covering != _covering) {
|
||||
_covering?.removeStatusListener(_coveringChanged);
|
||||
_covering = covering?..addStatusListener(_coveringChanged);
|
||||
}
|
||||
_visible = TabVisibility.of(context);
|
||||
_claim();
|
||||
}
|
||||
|
||||
void _coveringChanged(AnimationStatus _) => _claim();
|
||||
|
||||
/// Tells the frame whether this paints the fade, after the frame, as the
|
||||
/// frame is built above it.
|
||||
void _claim() {
|
||||
final claims = _foot?.claims;
|
||||
final inView = _visible && !(_covering?.isCompleted ?? false);
|
||||
final before = _claimed;
|
||||
_claimed = inView ? claims : null;
|
||||
if (before == _claimed) {
|
||||
return;
|
||||
}
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
before?.set(this, claims: false);
|
||||
if (mounted && claims != null && _claimed == claims) {
|
||||
claims.set(this, claims: true);
|
||||
}
|
||||
})
|
||||
..ensureVisualUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_covering?.removeStatusListener(_coveringChanged);
|
||||
if (_claimed case final claims?) {
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) => claims.set(this, claims: false))
|
||||
..ensureVisualUpdate();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_foot == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final background = context
|
||||
.dependOnInheritedWidgetOfExactType<_FrameFadeColour>()!
|
||||
.background;
|
||||
final keyboard = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
final height = keyboard
|
||||
? _height ?? MediaQuery.paddingOf(context).bottom
|
||||
: _height = MediaQuery.paddingOf(context).bottom;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FlushNavigationBar.fadeInto(background),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The screens in view that paint the fade above the bar themselves
|
||||
/// ([FrameFade]); the frame paints its own while none does.
|
||||
class _FadeClaims() extends ChangeNotifier {
|
||||
final _claims = <Object>{};
|
||||
bool _disposed = false;
|
||||
|
||||
/// Whether a screen in view paints the fade.
|
||||
bool get any => _claims.isNotEmpty;
|
||||
|
||||
/// Records whether [claimant] paints the fade; nothing once the frame has
|
||||
/// gone, as screens going with it tell it after.
|
||||
void set(Object claimant, {required bool claims}) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
if (claims ? _claims.add(claimant) : _claims.remove(claimant)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [FrameFade] below needs of the frame's bar: who paints the fade,
|
||||
/// and whether the bar shows.
|
||||
class const _FrameFoot({
|
||||
required final _FadeClaims claims,
|
||||
required final bool showsBar,
|
||||
required super.child,
|
||||
}) extends InheritedWidget {
|
||||
@override
|
||||
bool updateShouldNotify(_FrameFoot oldWidget) =>
|
||||
claims != oldWidget.claims || showsBar != oldWidget.showsBar;
|
||||
}
|
||||
|
||||
/// The background the fade ends in, apart from [_FrameFoot] as it changes
|
||||
/// with every frame of a change of colour, which only the fade follows.
|
||||
class const _FrameFadeColour({
|
||||
required final Color background,
|
||||
required super.child,
|
||||
}) extends InheritedWidget {
|
||||
@override
|
||||
bool updateShouldNotify(_FrameFadeColour oldWidget) =>
|
||||
background != oldWidget.background;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,15 +193,208 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
testWidgets("a tab's content leaves room for the bar, its fade and the "
|
||||
"system's bar; its floating button rises above the fade, the Scaffold "
|
||||
"keeping it clear of the system's bar itself", (tester) async {
|
||||
/// A tab's screen with a floating button, which paints the fade above
|
||||
/// the bar itself ([FrameFade]); its content's room in [cover].
|
||||
Widget fadingScreen({
|
||||
void Function(double cover)? cover,
|
||||
Key? key,
|
||||
bool resizes = true,
|
||||
}) => Builder(
|
||||
builder: (context) => Scaffold(
|
||||
key: key,
|
||||
extendBody: true,
|
||||
resizeToAvoidBottomInset: resizes,
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
cover?.call(FlushNavigationBar.coverOf(context));
|
||||
return ListView(
|
||||
key: const Key('tab'),
|
||||
children: [
|
||||
for (var i = 0; i < 30; i++)
|
||||
SizedBox(height: 60, child: Text('Row $i')),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
),
|
||||
);
|
||||
|
||||
/// The frame on a phone 400 × 800 with a system bar 24 high, around
|
||||
/// [child].
|
||||
Future<void> showPhone(WidgetTester tester, Widget child, {Widget? status}) {
|
||||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
return tester.pumpWidget(
|
||||
testApp(
|
||||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
status: status,
|
||||
destinations: const [
|
||||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||
],
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The fades painting: the frame's, or the screens'.
|
||||
Finder fades() => find.byKey(FlushNavigationBar.fadeKey);
|
||||
|
||||
/// Whether the fade painting is the screen's own, beneath its button.
|
||||
bool screenFades(WidgetTester tester) => find
|
||||
.descendant(of: find.byType(FrameFade), matching: fades())
|
||||
.evaluate()
|
||||
.isNotEmpty;
|
||||
|
||||
testWidgets("a tab's content leaves room for the bar, its fade and the "
|
||||
"system's bar, running on beneath them; its floating button stands 16 "
|
||||
'above the bar, over the fade, which the screen paints beneath it (the '
|
||||
"developer's review, 9 Nov 2026)", (tester) async {
|
||||
late double cover;
|
||||
await showPhone(tester, fadingScreen(cover: (room) => cover = room));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(cover, FlushNavigationBar.fadeHeight + 64 + 24);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const Key('tab'))).dy,
|
||||
800,
|
||||
reason: 'the content runs on beneath the fade and the bar',
|
||||
);
|
||||
final bar = tester.getRect(find.byKey(FlushNavigationBar.barKey));
|
||||
expect(
|
||||
tester.getRect(find.byType(FloatingActionButton)).bottom,
|
||||
bar.top - 16,
|
||||
);
|
||||
expect(fades(), findsOneWidget, reason: 'one fade, not two');
|
||||
expect(screenFades(tester), isTrue);
|
||||
expect(tester.getRect(fades()).bottom, bar.top);
|
||||
expect(tester.getRect(fades()).height, FlushNavigationBar.fadeHeight);
|
||||
});
|
||||
|
||||
testWidgets('the keyboard leaves the button where it stands on a screen '
|
||||
"that doesn't make way for it (Todo's)", (tester) async {
|
||||
await showPhone(tester, fadingScreen(resizes: false));
|
||||
await tester.pumpAndSettle();
|
||||
final before = tester.getRect(find.byType(FloatingActionButton));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getRect(find.byType(FloatingActionButton)), before);
|
||||
});
|
||||
|
||||
testWidgets("a screen without its own fade keeps the frame's", (
|
||||
tester,
|
||||
) async {
|
||||
await showPhone(tester, const Scaffold(body: Text('The tab')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fades(), findsOneWidget);
|
||||
expect(screenFades(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a menu or a dialog over the screen leaves its fade the '
|
||||
"screen's, beneath its button", (tester) async {
|
||||
await showPhone(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => Scaffold(
|
||||
extendBody: true,
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => const AlertDialog(content: Text('Hi')),
|
||||
),
|
||||
child: const Text('Ask'),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Ask'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Hi'), findsOneWidget);
|
||||
expect(fades(), findsOneWidget);
|
||||
expect(screenFades(tester), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a page over the screen in its tab without a fade of its own '
|
||||
"has the frame's, and back the screen's returns", (tester) async {
|
||||
final tab = GlobalKey<NavigatorState>();
|
||||
await showPhone(
|
||||
tester,
|
||||
Navigator(
|
||||
key: tab,
|
||||
onGenerateRoute: (_) =>
|
||||
MaterialPageRoute<void>(builder: (context) => fadingScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(screenFades(tester), isTrue);
|
||||
|
||||
tab.currentState!.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => const Scaffold(body: Text('Inside')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Inside'), findsOneWidget);
|
||||
expect(fades(), findsOneWidget);
|
||||
expect(screenFades(tester), isFalse);
|
||||
|
||||
tab.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(fades(), findsOneWidget);
|
||||
expect(screenFades(tester), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('with a status above the bar, the fade and the button stand '
|
||||
'above it', (tester) async {
|
||||
await showPhone(
|
||||
tester,
|
||||
fadingScreen(),
|
||||
status: const SizedBox(height: 40, child: Text('Recording')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final status = tester.getRect(find.text('Recording'));
|
||||
expect(tester.getRect(fades()).bottom, lessThanOrEqualTo(status.top));
|
||||
expect(
|
||||
tester.getRect(find.byType(FloatingActionButton)).bottom,
|
||||
lessThanOrEqualTo(tester.getRect(fades()).bottom - 16),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('from medium windows on, a screen with its own fade paints '
|
||||
'none, as there is no bar', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
NavigationFrame(
|
||||
|
|
@ -214,40 +407,13 @@ void main() {
|
|||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
cover = FlushNavigationBar.coverOf(context);
|
||||
return const SizedBox.expand(key: Key('tab'));
|
||||
},
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: fadingScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(cover, FlushNavigationBar.fadeHeight + 64 + 24);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byKey(const Key('tab'))).dy,
|
||||
800,
|
||||
reason: 'the content runs on beneath the fade and the bar',
|
||||
);
|
||||
expect(
|
||||
tester.getRect(find.byType(FloatingActionButton)).bottom,
|
||||
tester.getRect(find.byKey(FlushNavigationBar.fadeKey)).top - 16,
|
||||
);
|
||||
expect(fades(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a screen's snack bar stands above its floating button, so "
|
||||
|
|
@ -281,15 +447,13 @@ void main() {
|
|||
child: const Text('Complete'),
|
||||
),
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: FlushNavigationBar.buttonLiftOf(context),
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () => added++,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
extendBody: true,
|
||||
floatingActionButtonLocation: FrameFade.buttonLocation(context),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => added++,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
bottomNavigationBar: const FrameFade(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -367,14 +531,12 @@ void main() {
|
|||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
late double cover;
|
||||
late double lift;
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
cover = FlushNavigationBar.coverOf(context);
|
||||
lift = FlushNavigationBar.buttonLiftOf(context);
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
|
|
@ -383,7 +545,6 @@ void main() {
|
|||
);
|
||||
|
||||
expect(cover, 24);
|
||||
expect(lift, 0);
|
||||
});
|
||||
|
||||
testWidgets("from medium windows on, a rail with the app's icon on top, "
|
||||
|
|
|
|||
Loading…
Reference in a new issue