feat(ui,learn): the navigation bar in the colour of the screen in view
The developer's review of 8 Nov 2026: the bar follows the screen in view rather than one colour per app, so a Todo project of its own colour gives the bar that colour while it shows. The wave, the chosen destination's icon and label and its indicator take ScreenTint.color, the colour the screen in view claims through its TopGradient; the frame's accent, the app's colour, stays only for the moment before any screen claims one, since ScreenTint itself starts at cobalt, which would be wrong for Todo, Train and Meditate. Learn's tabs claim cobalt, their wash, so its bar is cobalt there now, not purple. A colour changing on the same tab (a project opening) passes into the next over 300 ms, at once with animations off. A wave paints in that colour as it goes: it starts in the colour of the screen being left, as the arriving one claims its own only once it shows, a frame later, and passes into it with the rest of the bar. A person's colours may be light, so the chosen destination's colour is made legible (readableTone, halkyon_ui): a palette colour starts from its own text tone as Todo's dates take it (its outline in light mode, its fill in dark), any other colour from itself, then darkens towards black in light mode and lightens towards white in dark, only until it reads at 4.5:1 on the bar and on its indicator. Every palette colour is tested in both themes, on the neutral and the tinted surface. This also lifts Learn's label, which read at about 3:1. The debug section gains Bar surface, neutral or tinted: tinted mixes 6 % of the screen's colour into the bar's own surface in light mode and 8 % in dark, changing with it. Neutral by default, and in the shipping style. Whats-New: The navigation bar takes the colour of the screen you're on, such as a project's own. Whats-New-De: Die Navigationsleiste nimmt die Farbe des Bildschirms an, auf dem du bist, etwa die eines Projekts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
45fd6595e1
commit
60d53ed262
12 changed files with 549 additions and 144 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.
|
- **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.
|
- **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.
|
- **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 in the app's colour over it, Learn's purple, the tab chosen in that colour, without Material's ripple or the indicator growing: `shippingNavigationBarStyle`, which a debug build's Settings switches live in its section Navigation bar (debug), halkyon_ui's `NavigationBarDebugSection`), 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, decided 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: `shippingNavigationBarStyle`, which a debug build's Settings switches live in its section Navigation bar (debug), halkyon_ui's `NavigationBarDebugSection`, which also tints the bar's surface with the screen's colour, neutral by default), 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*).
|
||||||
- **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.
|
- **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).
|
- **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`).
|
- **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`).
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
||||||
"the tab in view in Learn's purple, its icon's last colour", (
|
'the tab in view in the colour of the screen in view, legible on the '
|
||||||
|
"bar: Home's cobalt", (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await showApp(tester, width: 400);
|
await showApp(tester, width: 400);
|
||||||
|
|
@ -304,7 +305,15 @@ void main() {
|
||||||
DefaultTextStyle.of(tester.element(text)).style.color;
|
DefaultTextStyle.of(tester.element(text)).style.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(colorOf('Home'), PaletteColor.purple.background);
|
final bar = scheme.surfaceContainer;
|
||||||
|
expect(
|
||||||
|
colorOf('Home'),
|
||||||
|
readableTone(
|
||||||
|
Brand.primary,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
on: [bar, Color.alphaBlend(Brand.primary.withValues(alpha: 0.22), bar)],
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(colorOf('Library'), scheme.onSurfaceVariant);
|
expect(colorOf('Library'), scheme.onSurfaceVariant);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ void main() {
|
||||||
/// Signs out from Settings, which asks how to start.
|
/// Signs out from Settings, which asks how to start.
|
||||||
Future<void> signOut(WidgetTester tester, {bool passwords = true}) async {
|
Future<void> signOut(WidgetTester tester, {bool passwords = true}) async {
|
||||||
await openSettings(tester, passwords: passwords);
|
await openSettings(tester, passwords: passwords);
|
||||||
|
// Below the debug builds' section of the navigation bar's looks.
|
||||||
|
await tester.ensureVisible(find.text('Sign out'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sign out'));
|
await tester.tap(find.text('Sign out'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1491,8 +1491,10 @@ Strava. What was found, 27 Oct:
|
||||||
into the background of the screen in view above it replaces the floating card, so what
|
into the background of the screen in view above it replaces the floating card, so what
|
||||||
scrolls on beneath it fades out rather than being cut off; halkyon_ui's
|
scrolls on beneath it fades out rather than being cut off; halkyon_ui's
|
||||||
`FlushNavigationBar`, the rail unchanged; and, the same day, its top corners rounded,
|
`FlushNavigationBar`, the rail unchanged; and, the same day, its top corners rounded,
|
||||||
20 dp, a tap on another destination sending a wave in the app's colour over the bar
|
20 dp, a tap on another destination sending a wave over the bar from it, 1.2 s, in the
|
||||||
from it, 1.2 s, the destination chosen in that colour, without Material's ripple or
|
colour of the screen in view (a project's own while it shows, since 8 Nov 2026; the
|
||||||
|
app's before a screen claims one), the destination chosen in that colour, darkened or
|
||||||
|
lightened until it reads at 4.5:1, without Material's ripple or
|
||||||
the indicator growing, the variant E of the developer's mockup, which a debug build's
|
the indicator growing, the variant E of the developer's mockup, which a debug build's
|
||||||
Settings switches live among the others). Driven by
|
Settings switches live among the others). Driven by
|
||||||
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ LeanCode's lints.
|
||||||
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
|
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
|
||||||
app's frame around its tabs (the rail with the app's icon, the bar flush
|
app's frame around its tabs (the rail with the app's icon, the bar flush
|
||||||
with the foot with a fade above it, its rounded top corners and the
|
with the foot with a fade above it, its rounded top corners and the
|
||||||
wave in the app's colour a tap sends over it, `shippingNavigationBarStyle`,
|
wave a tap sends over it in the colour of the screen in view, the tab
|
||||||
|
chosen in that colour made legible (`readableTone`),
|
||||||
|
`shippingNavigationBarStyle`,
|
||||||
switched live in a debug build's `NavigationBarDebugSection`, a tick on every choice, a status such as a recording under way above
|
switched live in a debug build's `NavigationBarDebugSection`, a tick on every choice, a status such as a recording under way above
|
||||||
the bar), whichever router drives it;
|
the bar), whichever router drives it;
|
||||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export 'src/theme/app_fonts.dart';
|
||||||
export 'src/theme/app_theme.dart';
|
export 'src/theme/app_theme.dart';
|
||||||
export 'src/theme/brand.dart';
|
export 'src/theme/brand.dart';
|
||||||
export 'src/theme/palette_color.dart';
|
export 'src/theme/palette_color.dart';
|
||||||
|
export 'src/theme/readable_tone.dart';
|
||||||
export 'src/theme/theme_extras.dart';
|
export 'src/theme/theme_extras.dart';
|
||||||
export 'src/widget/adaptive_sheet.dart';
|
export 'src/widget/adaptive_sheet.dart';
|
||||||
export 'src/widget/app_loading_indicator.dart';
|
export 'src/widget/app_loading_indicator.dart';
|
||||||
|
|
|
||||||
35
packages/halkyon_ui/lib/src/theme/readable_tone.dart
Normal file
35
packages/halkyon_ui/lib/src/theme/readable_tone.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:halkyon_ui/src/theme/palette_color.dart';
|
||||||
|
import 'package:halkyon_ui/src/widget/top_gradient.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// [color] as text and icons on every one of [on] in a theme of
|
||||||
|
/// [brightness], at [minContrast] at least (WCAG AA for text): a palette
|
||||||
|
/// colour starts from its own text tone (its outline in light mode, its
|
||||||
|
/// fill in dark, as Todo's dates take it), any other colour from itself,
|
||||||
|
/// then darkens towards black in light mode, lightens towards white in
|
||||||
|
/// dark, only as far as it must.
|
||||||
|
Color readableTone(
|
||||||
|
Color color, {
|
||||||
|
required Brightness brightness,
|
||||||
|
required List<Color> on,
|
||||||
|
double minContrast = TopGradient.minContrast,
|
||||||
|
}) {
|
||||||
|
final dark = brightness == Brightness.dark;
|
||||||
|
final palette = PaletteColor.values
|
||||||
|
.where((palette) => palette.background == color)
|
||||||
|
.firstOrNull;
|
||||||
|
final start = switch (palette) {
|
||||||
|
final palette? => dark ? palette.background : palette.outline,
|
||||||
|
null => color,
|
||||||
|
};
|
||||||
|
final end = dark ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
|
||||||
|
for (var step = 0; step <= 40; step++) {
|
||||||
|
final tone = Color.lerp(start, end, step / 40)!;
|
||||||
|
if (on.every(
|
||||||
|
(surface) => TopGradient.contrast(tone, surface) >= minContrast,
|
||||||
|
)) {
|
||||||
|
return tone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
@ -28,9 +28,13 @@ class const FlushNavigationBar({
|
||||||
/// The background of the screen in view, which the fade ends in.
|
/// The background of the screen in view, which the fade ends in.
|
||||||
required final Color background,
|
required final Color background,
|
||||||
|
|
||||||
/// The app's colour, the wave's.
|
/// The colour of the screen in view, the wave's.
|
||||||
required final Color accent,
|
required final Color accent,
|
||||||
|
|
||||||
|
/// The bar's own colour, beneath the wave: the theme's, or a little of
|
||||||
|
/// the screen's colour mixed in.
|
||||||
|
required final Color surface,
|
||||||
|
|
||||||
/// How many destinations the bar has, for where the wave starts.
|
/// How many destinations the bar has, for where the wave starts.
|
||||||
required final int destinationCount,
|
required final int destinationCount,
|
||||||
|
|
||||||
|
|
@ -80,7 +84,6 @@ class const FlushNavigationBar({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = this.status;
|
final status = this.status;
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -116,12 +119,7 @@ class const FlushNavigationBar({
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ColoredBox(
|
child: ColoredBox(color: surface),
|
||||||
// Material 3's bar colour, as the bar would paint itself.
|
|
||||||
color:
|
|
||||||
NavigationBarTheme.of(context).backgroundColor ??
|
|
||||||
theme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: _WaveLayer(
|
child: _WaveLayer(
|
||||||
|
|
|
||||||
|
|
@ -29,19 +29,23 @@ enum NavigationBarVariant(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The navigation bar's look: its variant, how long the wave takes, how
|
/// The navigation bar's look: its variant, how long the wave takes, how
|
||||||
/// round the bar's top corners are.
|
/// round the bar's top corners are, and whether the bar's surface takes a
|
||||||
|
/// little of the screen's colour.
|
||||||
typedef NavigationBarStyle = ({
|
typedef NavigationBarStyle = ({
|
||||||
NavigationBarVariant variant,
|
NavigationBarVariant variant,
|
||||||
Duration waveDuration,
|
Duration waveDuration,
|
||||||
double cornerRadius,
|
double cornerRadius,
|
||||||
|
bool tintedSurface,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The look every release build has: the wave without the fill, 1.2 s,
|
/// The look every release build has: the wave without the fill, 1.2 s,
|
||||||
/// corners of 20 dp (the developer's choice, 7 Nov 2026).
|
/// corners of 20 dp (the developer's choice, 7 Nov 2026), on the bar's own
|
||||||
|
/// neutral surface.
|
||||||
const NavigationBarStyle shippingNavigationBarStyle = (
|
const NavigationBarStyle shippingNavigationBarStyle = (
|
||||||
variant: NavigationBarVariant.waveWithoutFill,
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
waveDuration: Duration(milliseconds: 1200),
|
waveDuration: Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20,
|
cornerRadius: 20,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The look a debug build shows, switched live in Settings' debug section
|
/// The look a debug build shows, switched live in Settings' debug section
|
||||||
|
|
@ -57,7 +61,8 @@ NavigationBarStyle effectiveNavigationBarStyle({bool debug = kDebugMode}) =>
|
||||||
debug ? debugNavigationBarStyle.value : shippingNavigationBarStyle;
|
debug ? debugNavigationBarStyle.value : shippingNavigationBarStyle;
|
||||||
|
|
||||||
/// Settings' section switching the navigation bar's look live while testing
|
/// Settings' section switching the navigation bar's look live while testing
|
||||||
/// on a phone: variant, the wave's time, the corners. In debug builds
|
/// on a phone: variant, the wave's time, the corners, the bar's surface
|
||||||
|
/// neutral or tinted with the screen's colour. In debug builds
|
||||||
/// alone, and in English alone, as no release ever shows it. Every app puts
|
/// alone, and in English alone, as no release ever shows it. Every app puts
|
||||||
/// it before About; to remove it, delete the section from the apps' Settings
|
/// it before About; to remove it, delete the section from the apps' Settings
|
||||||
/// and [debugNavigationBarStyle], and let the frame take
|
/// and [debugNavigationBarStyle], and let the frame take
|
||||||
|
|
@ -81,8 +86,17 @@ class const NavigationBarDebugSection({
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: debugNavigationBarStyle,
|
valueListenable: debugNavigationBarStyle,
|
||||||
builder: (context, style, _) {
|
builder: (context, style, _) {
|
||||||
void set(NavigationBarStyle next) =>
|
void set({
|
||||||
debugNavigationBarStyle.value = next;
|
NavigationBarVariant? variant,
|
||||||
|
Duration? waveDuration,
|
||||||
|
double? cornerRadius,
|
||||||
|
bool? tintedSurface,
|
||||||
|
}) => debugNavigationBarStyle.value = (
|
||||||
|
variant: variant ?? style.variant,
|
||||||
|
waveDuration: waveDuration ?? style.waveDuration,
|
||||||
|
cornerRadius: cornerRadius ?? style.cornerRadius,
|
||||||
|
tintedSurface: tintedSurface ?? style.tintedSurface,
|
||||||
|
);
|
||||||
String seconds(int ms) =>
|
String seconds(int ms) =>
|
||||||
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
|
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
@ -101,33 +115,29 @@ class const NavigationBarDebugSection({
|
||||||
values: NavigationBarVariant.values,
|
values: NavigationBarVariant.values,
|
||||||
selected: style.variant,
|
selected: style.variant,
|
||||||
name: (variant) => variant.letter,
|
name: (variant) => variant.letter,
|
||||||
onSelected: (variant) => set((
|
onSelected: (variant) => set(variant: variant),
|
||||||
variant: variant,
|
|
||||||
waveDuration: style.waveDuration,
|
|
||||||
cornerRadius: style.cornerRadius,
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
_Choice(
|
_Choice(
|
||||||
label: 'Wave',
|
label: 'Wave',
|
||||||
values: durations,
|
values: durations,
|
||||||
selected: style.waveDuration.inMilliseconds,
|
selected: style.waveDuration.inMilliseconds,
|
||||||
name: seconds,
|
name: seconds,
|
||||||
onSelected: (ms) => set((
|
onSelected: (ms) =>
|
||||||
variant: style.variant,
|
set(waveDuration: Duration(milliseconds: ms)),
|
||||||
waveDuration: Duration(milliseconds: ms),
|
|
||||||
cornerRadius: style.cornerRadius,
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
_Choice(
|
_Choice(
|
||||||
label: 'Corners',
|
label: 'Corners',
|
||||||
values: corners,
|
values: corners,
|
||||||
selected: style.cornerRadius,
|
selected: style.cornerRadius,
|
||||||
name: (radius) => radius.toStringAsFixed(0),
|
name: (radius) => radius.toStringAsFixed(0),
|
||||||
onSelected: (radius) => set((
|
onSelected: (radius) => set(cornerRadius: radius),
|
||||||
variant: style.variant,
|
),
|
||||||
waveDuration: style.waveDuration,
|
_Choice(
|
||||||
cornerRadius: radius,
|
label: 'Bar surface',
|
||||||
)),
|
values: const [false, true],
|
||||||
|
selected: style.tintedSurface,
|
||||||
|
name: (tinted) => tinted ? 'Tinted' : 'Neutral',
|
||||||
|
onSelected: (tinted) => set(tintedSurface: tinted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:halkyon_ui/src/layout/window_size.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/flush_navigation_bar.dart';
|
||||||
import 'package:halkyon_ui/src/widget/navigation_bar_style.dart';
|
import 'package:halkyon_ui/src/widget/navigation_bar_style.dart';
|
||||||
import 'package:halkyon_ui/src/widget/screen_tint.dart';
|
import 'package:halkyon_ui/src/widget/screen_tint.dart';
|
||||||
|
|
@ -35,10 +36,11 @@ typedef FrameDestination = ({
|
||||||
///
|
///
|
||||||
/// The bar's look is [shippingNavigationBarStyle] (the developer's choice,
|
/// The bar's look is [shippingNavigationBarStyle] (the developer's choice,
|
||||||
/// 7 Nov 2026), in debug builds the one Settings' debug section switches
|
/// 7 Nov 2026), in debug builds the one Settings' debug section switches
|
||||||
/// to: a tap on another destination sends a wave in [accent] over the bar
|
/// to: a tap on another destination sends a wave over the bar from it,
|
||||||
/// from it, unless animations are off, and the destination chosen takes
|
/// unless animations are off, and the destination chosen takes the colour
|
||||||
/// [accent]; a tab changed otherwise (a link, back) sends none, as the wave
|
/// of the screen in view ([ScreenTint.color], [accent] until a screen
|
||||||
/// answers the finger.
|
/// claims one), legible on the bar; a tab changed otherwise (a link, back)
|
||||||
|
/// sends none, as the wave answers the finger.
|
||||||
class const NavigationFrame({
|
class const NavigationFrame({
|
||||||
/// The tabs, in order.
|
/// The tabs, in order.
|
||||||
required final List<FrameDestination> destinations,
|
required final List<FrameDestination> destinations,
|
||||||
|
|
@ -56,8 +58,9 @@ class const NavigationFrame({
|
||||||
required final ImageProvider icon,
|
required final ImageProvider icon,
|
||||||
|
|
||||||
/// The app's colour, its icon's last and its screens' wash (Learn's
|
/// The app's colour, its icon's last and its screens' wash (Learn's
|
||||||
/// purple, Todo's strawberry, Train's carrot, Meditate's fern): the
|
/// purple, Todo's strawberry, Train's carrot, Meditate's fern): the bar's
|
||||||
/// wave's and the destination chosen's on the bar.
|
/// until a screen claims a colour of its own ([ScreenTint.color]), which
|
||||||
|
/// the bar takes from then on.
|
||||||
required final Color accent,
|
required final Color accent,
|
||||||
|
|
||||||
/// The tab's screens.
|
/// The tab's screens.
|
||||||
|
|
@ -155,10 +158,74 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
}
|
}
|
||||||
final style = effectiveNavigationBarStyle();
|
final style = effectiveNavigationBarStyle();
|
||||||
final variant = style.variant;
|
final variant = style.variant;
|
||||||
final accent = widget.accent;
|
|
||||||
final withoutFill =
|
final withoutFill =
|
||||||
variant == NavigationBarVariant.waveWithoutFill ||
|
variant == NavigationBarVariant.waveWithoutFill ||
|
||||||
variant == NavigationBarVariant.waveWithoutIndicator;
|
variant == NavigationBarVariant.waveWithoutIndicator;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final surface = theme.colorScheme.surface;
|
||||||
|
final neutral =
|
||||||
|
NavigationBarTheme.of(context).backgroundColor ??
|
||||||
|
theme.colorScheme.surfaceContainer;
|
||||||
|
final dark = theme.brightness == Brightness.dark;
|
||||||
|
final still = MediaQuery.disableAnimationsOf(context);
|
||||||
|
const colourChange = Duration(milliseconds: 300);
|
||||||
|
return ScreenTintScope(
|
||||||
|
notifier: _tint,
|
||||||
|
// Behind the screens and in the fade above the bar, the background of
|
||||||
|
// the screen in view, changing as the frame's colour does; on the bar,
|
||||||
|
// the colour of the screen in view (the app's until one claims its
|
||||||
|
// own), changing over 300 ms.
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _tint,
|
||||||
|
builder: (context, _) {
|
||||||
|
final claimed = _tint.background != null;
|
||||||
|
final target = claimed ? _tint.color : widget.accent;
|
||||||
|
return TweenAnimationBuilder<Color?>(
|
||||||
|
tween: ColorTween(end: _tint.background ?? surface),
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
builder: (context, background, _) => TweenAnimationBuilder<Color?>(
|
||||||
|
tween: ColorTween(
|
||||||
|
end: style.tintedSurface
|
||||||
|
? Color.alphaBlend(
|
||||||
|
target.withValues(alpha: dark ? 0.08 : 0.06),
|
||||||
|
neutral,
|
||||||
|
)
|
||||||
|
: neutral,
|
||||||
|
),
|
||||||
|
duration: still ? Duration.zero : colourChange,
|
||||||
|
builder: (context, barSurface, _) =>
|
||||||
|
TweenAnimationBuilder<Color?>(
|
||||||
|
tween: ColorTween(end: target),
|
||||||
|
duration: still ? Duration.zero : colourChange,
|
||||||
|
builder: (context, colour, _) => _phone(
|
||||||
|
context,
|
||||||
|
style: style,
|
||||||
|
withoutFill: withoutFill,
|
||||||
|
background: background ?? surface,
|
||||||
|
barSurface: barSurface ?? neutral,
|
||||||
|
colour: colour ?? target,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame below medium windows, its bar in [colour] on [barSurface],
|
||||||
|
/// the screens on [background].
|
||||||
|
Widget _phone(
|
||||||
|
BuildContext context, {
|
||||||
|
required NavigationBarStyle style,
|
||||||
|
required bool withoutFill,
|
||||||
|
required Color background,
|
||||||
|
required Color barSurface,
|
||||||
|
required Color colour,
|
||||||
|
}) {
|
||||||
|
final variant = style.variant;
|
||||||
|
final destinations = widget.destinations;
|
||||||
|
final indicator = colour.withValues(alpha: 0.22);
|
||||||
final Widget bar = NavigationBar(
|
final Widget bar = NavigationBar(
|
||||||
// The flush bar paints the bar's colour beneath the wave.
|
// The flush bar paints the bar's colour beneath the wave.
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|
@ -168,7 +235,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
indicatorColor: switch (variant) {
|
indicatorColor: switch (variant) {
|
||||||
NavigationBarVariant.material => null,
|
NavigationBarVariant.material => null,
|
||||||
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
|
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
|
||||||
_ => accent.withValues(alpha: 0.22),
|
_ => indicator,
|
||||||
},
|
},
|
||||||
// Without the fill: no ripple, the indicator there at once.
|
// Without the fill: no ripple, the indicator there at once.
|
||||||
overlayColor: withoutFill
|
overlayColor: withoutFill
|
||||||
|
|
@ -184,31 +251,20 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
final surface = Theme.of(context).colorScheme.surface;
|
return Scaffold(
|
||||||
return ScreenTintScope(
|
|
||||||
notifier: _tint,
|
|
||||||
// Behind the screens and in the fade above the bar, the background of
|
|
||||||
// the screen in view, changing as the frame's colour does.
|
|
||||||
child: ListenableBuilder(
|
|
||||||
listenable: _tint,
|
|
||||||
builder: (context, _) => TweenAnimationBuilder<Color?>(
|
|
||||||
tween: ColorTween(end: _tint.background ?? surface),
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
builder: (context, background, _) => Scaffold(
|
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
// The screens run on beneath the bar's fade and the bar, fading
|
// The screens run on beneath the bar's fade and the bar, fading out
|
||||||
// out above it; their lists end with room for both (the body's
|
// above it; their lists end with room for both (the body's bottom
|
||||||
// bottom padding says how much, FlushNavigationBar.coverOf).
|
// padding says how much, FlushNavigationBar.coverOf).
|
||||||
extendBody: !widget.hidesNavigationBar,
|
extendBody: !widget.hidesNavigationBar,
|
||||||
// The keyboard is each screen's own to make way for, its own
|
// The keyboard is each screen's own to make way for, its own scaffold
|
||||||
// scaffold resizing as it does by default: a screen with a field
|
// resizing as it does by default: a screen with a field floating over
|
||||||
// floating over it keeps its size and lifts the field instead
|
// it keeps its size and lifts the field instead (Halkyon Learn's ADR
|
||||||
// (Halkyon Learn's ADR 0021, D69).
|
// 0021, D69).
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
body: widget.child,
|
body: widget.child,
|
||||||
// Without the bar, a strip as high as the system's own bar, in
|
// Without the bar, a strip as high as the system's own bar, in the
|
||||||
// the screen's background, keeps every screen inside clear of
|
// screen's background, keeps every screen inside clear of it.
|
||||||
// it.
|
|
||||||
bottomNavigationBar: widget.hidesNavigationBar
|
bottomNavigationBar: widget.hidesNavigationBar
|
||||||
? Column(
|
? Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -222,20 +278,31 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: FlushNavigationBar(
|
: FlushNavigationBar(
|
||||||
background: background ?? surface,
|
background: background,
|
||||||
accent: accent,
|
surface: barSurface,
|
||||||
|
accent: colour,
|
||||||
destinationCount: destinations.length,
|
destinationCount: destinations.length,
|
||||||
wave: _wave,
|
wave: _wave,
|
||||||
waveDuration: style.waveDuration,
|
waveDuration: style.waveDuration,
|
||||||
cornerRadius: style.cornerRadius,
|
cornerRadius: style.cornerRadius,
|
||||||
status: widget.status,
|
status: widget.status,
|
||||||
bar: variant.waves
|
bar: variant.waves
|
||||||
? _Chosen(accent: accent, child: 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 ones lightened in dark.
|
||||||
|
accent: readableTone(
|
||||||
|
colour,
|
||||||
|
brightness: Theme.of(context).brightness,
|
||||||
|
on: [
|
||||||
|
barSurface,
|
||||||
|
Color.alphaBlend(indicator, barSurface),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: bar,
|
||||||
|
)
|
||||||
: bar,
|
: bar,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
249
packages/halkyon_ui/test/widget/navigation_bar_colour_test.dart
Normal file
249
packages/halkyon_ui/test/widget/navigation_bar_colour_test.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../support/test_app.dart';
|
||||||
|
|
||||||
|
/// The navigation bar takes the colour of the screen in view (the
|
||||||
|
/// developer's review, 8 Nov 2026): a project of its own colour colours the
|
||||||
|
/// bar while it shows, the app's colour before any screen claims one, every
|
||||||
|
/// tone legible on the bar in both themes.
|
||||||
|
void main() {
|
||||||
|
/// A 1 × 1 PNG, the app's icon in these tests.
|
||||||
|
final icon = MemoryImage(
|
||||||
|
Uint8List.fromList(const [
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, //
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
|
||||||
|
0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0x05, 0x00, 0x01, 0x7A, 0x5E, 0xAB, 0x3F, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final accent = PaletteColor.strawberry.background;
|
||||||
|
|
||||||
|
tearDown(() => debugNavigationBarStyle.value = shippingNavigationBarStyle);
|
||||||
|
|
||||||
|
/// The frame around [child] on a phone, in [brightness].
|
||||||
|
Future<void> show(
|
||||||
|
WidgetTester tester,
|
||||||
|
Widget child, {
|
||||||
|
Brightness brightness = Brightness.light,
|
||||||
|
bool disableAnimations = false,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(400, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
testApp(
|
||||||
|
brightness: brightness,
|
||||||
|
Builder(
|
||||||
|
builder: (context) => MediaQuery(
|
||||||
|
data: MediaQuery.of(
|
||||||
|
context,
|
||||||
|
).copyWith(disableAnimations: disableAnimations),
|
||||||
|
child: NavigationFrame(
|
||||||
|
appName: 'Halkyon Test',
|
||||||
|
icon: icon,
|
||||||
|
accent: accent,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
destinations: const [
|
||||||
|
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||||
|
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||||
|
],
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A screen claiming [color] while it shows.
|
||||||
|
Widget screen(Color color) => TopGradient(
|
||||||
|
color: color,
|
||||||
|
background: Colors.white,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Color? chosenIcon(WidgetTester tester) =>
|
||||||
|
IconTheme.of(tester.element(find.byIcon(Icons.home))).color;
|
||||||
|
Color? indicator(WidgetTester tester) =>
|
||||||
|
tester.widget<NavigationBar>(find.byType(NavigationBar)).indicatorColor;
|
||||||
|
Color barSurface(WidgetTester tester) => tester
|
||||||
|
.widget<ColoredBox>(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byKey(FlushNavigationBar.barKey),
|
||||||
|
matching: find.byType(ColoredBox),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.color;
|
||||||
|
Color legible(WidgetTester tester, Color color) {
|
||||||
|
final theme = Theme.of(tester.element(find.byType(NavigationBar)));
|
||||||
|
final surface = barSurface(tester);
|
||||||
|
return readableTone(
|
||||||
|
color,
|
||||||
|
brightness: theme.brightness,
|
||||||
|
on: [surface, Color.alphaBlend(color.withValues(alpha: 0.22), surface)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets("before a screen claims a colour, the bar takes the app's", (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await show(tester, const SizedBox.expand());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(indicator(tester), accent.withValues(alpha: 0.22));
|
||||||
|
expect(chosenIcon(tester), legible(tester, accent));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the bar takes the colour the screen in view claims, and '
|
||||||
|
'changes with it over 300 ms', (tester) async {
|
||||||
|
final project = PaletteColor.ocean.background;
|
||||||
|
await show(tester, screen(project));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(indicator(tester), project.withValues(alpha: 0.22));
|
||||||
|
expect(chosenIcon(tester), legible(tester, project));
|
||||||
|
|
||||||
|
// Another project opens on the same tab.
|
||||||
|
final other = PaletteColor.olive.background;
|
||||||
|
await show(tester, screen(other));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
expect(
|
||||||
|
indicator(tester),
|
||||||
|
isNot(
|
||||||
|
anyOf(project.withValues(alpha: 0.22), other.withValues(alpha: 0.22)),
|
||||||
|
),
|
||||||
|
reason: 'half way',
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
expect(indicator(tester), other.withValues(alpha: 0.22));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('with animations off, the colour changes at once', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await show(tester, screen(PaletteColor.ocean.background));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final other = PaletteColor.olive.background;
|
||||||
|
await show(tester, screen(other), disableAnimations: true);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(indicator(tester), other.withValues(alpha: 0.22));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('every palette colour is legible on the bar', () {
|
||||||
|
final byLuminance = [...PaletteColor.values]
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.background.computeLuminance().compareTo(
|
||||||
|
b.background.computeLuminance(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final lightest = byLuminance.last;
|
||||||
|
final darkest = byLuminance.first;
|
||||||
|
|
||||||
|
for (final brightness in Brightness.values) {
|
||||||
|
for (final tinted in [false, true]) {
|
||||||
|
testWidgets('in the $brightness theme, the bar '
|
||||||
|
'${tinted ? 'tinted' : 'neutral'}: the lightest '
|
||||||
|
'(${lightest.name}), the darkest (${darkest.name}) and every '
|
||||||
|
'other', (tester) async {
|
||||||
|
debugNavigationBarStyle.value = (
|
||||||
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: tinted,
|
||||||
|
);
|
||||||
|
for (final color in [lightest, darkest, ...PaletteColor.values]) {
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
screen(color.background),
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final tone = chosenIcon(tester)!;
|
||||||
|
final surface = barSurface(tester);
|
||||||
|
final onIndicator = Color.alphaBlend(
|
||||||
|
color.background.withValues(alpha: 0.22),
|
||||||
|
surface,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
TopGradient.contrast(tone, surface),
|
||||||
|
greaterThanOrEqualTo(4.5),
|
||||||
|
reason: '${color.name} on the bar',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
TopGradient.contrast(tone, onIndicator),
|
||||||
|
greaterThanOrEqualTo(4.5),
|
||||||
|
reason: '${color.name} on its indicator',
|
||||||
|
);
|
||||||
|
final label = find.text('Home');
|
||||||
|
expect(
|
||||||
|
tester.widget<Text>(label).style?.color ??
|
||||||
|
DefaultTextStyle.of(tester.element(label)).style.color,
|
||||||
|
tone,
|
||||||
|
reason: '${color.name}: the label as the icon',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("a colour legible already keeps the palette's own text tone: "
|
||||||
|
'its outline in light mode, its fill in dark', (tester) async {
|
||||||
|
const light = Color(0xFFF3F3F3);
|
||||||
|
const dark = Color(0xFF1A1B24);
|
||||||
|
expect(
|
||||||
|
readableTone(
|
||||||
|
PaletteColor.cobalt.background,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
on: [light],
|
||||||
|
),
|
||||||
|
PaletteColor.cobalt.outline,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readableTone(
|
||||||
|
PaletteColor.purple.background,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
on: [dark],
|
||||||
|
),
|
||||||
|
PaletteColor.purple.background,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("the bar's surface stays neutral unless tinted, which mixes in "
|
||||||
|
'6 % of the colour in light mode and 8 % in dark', (tester) async {
|
||||||
|
final project = PaletteColor.ocean.background;
|
||||||
|
for (final brightness in Brightness.values) {
|
||||||
|
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
||||||
|
await show(tester, screen(project), brightness: brightness);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final neutral = Theme.of(
|
||||||
|
tester.element(find.byType(NavigationBar)),
|
||||||
|
).colorScheme.surfaceContainer;
|
||||||
|
expect(barSurface(tester), neutral, reason: '$brightness');
|
||||||
|
|
||||||
|
debugNavigationBarStyle.value = (
|
||||||
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: true,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final share = brightness == Brightness.dark ? 0.08 : 0.06;
|
||||||
|
expect(
|
||||||
|
barSurface(tester),
|
||||||
|
Color.alphaBlend(project.withValues(alpha: share), neutral),
|
||||||
|
reason: '$brightness',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -76,11 +76,13 @@ void main() {
|
||||||
final wave = find.byKey(FlushNavigationBar.waveKey);
|
final wave = find.byKey(FlushNavigationBar.waveKey);
|
||||||
|
|
||||||
testWidgets('the shipping look is the wave without the fill, 1.2 s, with '
|
testWidgets('the shipping look is the wave without the fill, 1.2 s, with '
|
||||||
'corners of 20, which the debug section starts from', (tester) async {
|
'corners of 20 on a neutral surface, which the debug section starts '
|
||||||
|
'from', (tester) async {
|
||||||
expect(shippingNavigationBarStyle, (
|
expect(shippingNavigationBarStyle, (
|
||||||
variant: NavigationBarVariant.waveWithoutFill,
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
));
|
));
|
||||||
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
|
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
|
||||||
});
|
});
|
||||||
|
|
@ -112,6 +114,7 @@ void main() {
|
||||||
variant: NavigationBarVariant.waveWithoutFill,
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
waveDuration: const Duration(seconds: 2),
|
waveDuration: const Duration(seconds: 2),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(following());
|
await tester.pumpWidget(following());
|
||||||
await tester.tap(find.text('Maps'));
|
await tester.tap(find.text('Maps'));
|
||||||
|
|
@ -153,6 +156,7 @@ void main() {
|
||||||
variant: NavigationBarVariant.material,
|
variant: NavigationBarVariant.material,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(following());
|
await tester.pumpWidget(following());
|
||||||
await tester.tap(find.text('Maps'));
|
await tester.tap(find.text('Maps'));
|
||||||
|
|
@ -161,35 +165,52 @@ void main() {
|
||||||
expect(wave, findsNothing);
|
expect(wave, findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the destination chosen is in the app colour, its indicator '
|
testWidgets(
|
||||||
|
'the destination chosen is in the app colour, legible, its indicator '
|
||||||
'tinted with it; without the fill no ripple and no growing indicator; '
|
'tinted with it; without the fill no ripple and no growing indicator; '
|
||||||
'the last variant without the indicator', (tester) async {
|
'the last variant without the indicator',
|
||||||
|
(tester) async {
|
||||||
await phone(tester);
|
await phone(tester);
|
||||||
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
|
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
|
||||||
Color? iconColor(IconData data) => IconTheme.of(
|
Color? iconColor(IconData data) => IconTheme.of(
|
||||||
tester.element(find.byIcon(data)),
|
tester.element(find.byIcon(data)),
|
||||||
).color;
|
).color;
|
||||||
|
// The app colour as legible on the bar and on its indicator.
|
||||||
|
Color tone() {
|
||||||
|
final surface = Theme.of(
|
||||||
|
tester.element(find.byType(NavigationBar)),
|
||||||
|
).colorScheme.surfaceContainer;
|
||||||
|
return readableTone(
|
||||||
|
accent,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
on: [
|
||||||
|
surface,
|
||||||
|
Color.alphaBlend(accent.withValues(alpha: 0.22), surface),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
debugNavigationBarStyle.value = (
|
debugNavigationBarStyle.value = (
|
||||||
variant: NavigationBarVariant.wave,
|
variant: NavigationBarVariant.wave,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(frame(selected: 1));
|
await tester.pumpWidget(frame(selected: 1));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(iconColor(Icons.map), accent);
|
expect(iconColor(Icons.map), tone());
|
||||||
expect(iconColor(Icons.home), isNot(accent));
|
expect(iconColor(Icons.home), isNot(tone()));
|
||||||
expect(
|
expect(
|
||||||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||||||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||||||
accent,
|
tone(),
|
||||||
);
|
);
|
||||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||||
expect(bar().overlayColor, isNull, reason: 'with the fill');
|
expect(bar().overlayColor, isNull, reason: 'with the fill');
|
||||||
|
|
||||||
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(iconColor(Icons.map), accent);
|
expect(iconColor(Icons.map), tone());
|
||||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||||
expect(
|
expect(
|
||||||
bar().overlayColor?.resolve({WidgetState.pressed}),
|
bar().overlayColor?.resolve({WidgetState.pressed}),
|
||||||
|
|
@ -201,20 +222,23 @@ void main() {
|
||||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(iconColor(Icons.map), accent);
|
expect(iconColor(Icons.map), tone());
|
||||||
expect(bar().indicatorColor, Colors.transparent);
|
expect(bar().indicatorColor, Colors.transparent);
|
||||||
|
|
||||||
debugNavigationBarStyle.value = (
|
debugNavigationBarStyle.value = (
|
||||||
variant: NavigationBarVariant.material,
|
variant: NavigationBarVariant.material,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 20.0,
|
cornerRadius: 20.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(iconColor(Icons.map), isNot(accent));
|
expect(iconColor(Icons.map), isNot(tone()));
|
||||||
expect(bar().indicatorColor, isNull);
|
expect(bar().indicatorColor, isNull);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets("the bar's top corners are rounded as the style says, the "
|
testWidgets("the bar's top corners are rounded as the style says, the "
|
||||||
'screen showing through them', (tester) async {
|
'screen showing through them', (tester) async {
|
||||||
|
|
@ -230,6 +254,7 @@ void main() {
|
||||||
variant: NavigationBarVariant.waveWithoutFill,
|
variant: NavigationBarVariant.waveWithoutFill,
|
||||||
waveDuration: const Duration(milliseconds: 1200),
|
waveDuration: const Duration(milliseconds: 1200),
|
||||||
cornerRadius: 0.0,
|
cornerRadius: 0.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(corners(), BorderRadius.zero);
|
expect(corners(), BorderRadius.zero);
|
||||||
|
|
@ -253,10 +278,13 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.text('28'));
|
await tester.tap(find.text('28'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Tinted'));
|
||||||
|
await tester.pump();
|
||||||
expect(debugNavigationBarStyle.value, (
|
expect(debugNavigationBarStyle.value, (
|
||||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||||
waveDuration: const Duration(seconds: 2),
|
waveDuration: const Duration(seconds: 2),
|
||||||
cornerRadius: 28.0,
|
cornerRadius: 28.0,
|
||||||
|
tintedSurface: true,
|
||||||
));
|
));
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -277,6 +305,7 @@ void main() {
|
||||||
variant: NavigationBarVariant.material,
|
variant: NavigationBarVariant.material,
|
||||||
waveDuration: const Duration(seconds: 2),
|
waveDuration: const Duration(seconds: 2),
|
||||||
cornerRadius: 0.0,
|
cornerRadius: 0.0,
|
||||||
|
tintedSurface: false,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
effectiveNavigationBarStyle(debug: false),
|
effectiveNavigationBarStyle(debug: false),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue