feat(ui,learn,todo,train,meditate): a wave in the app's colour over the navigation bar
The developer's choice of 7 Nov 2026 (the mockup's variant E): a tap on another destination sends a radial glow in the app's colour over the bar from that destination's centre, solid to 35 % of its radius and gone at 70 %, growing to cover the bar (cubic(.2, .7, .2, 1)) while it fades from half its strength in dark mode and a third in light, over 1.2 s, behind the destinations and clipped to the bar. None for the destination already chosen, none with animations off, and none for a tab changed without a tap (a link, back): the wave answers the finger. The destination chosen takes the app's colour, its indicator tinted 22 % with it, without Material's ripple or the indicator growing; the bar's top corners are rounded, 20 dp, the screen showing through them. Each app gives the frame its colour (NavigationFrame's accent, from PaletteColor): Learn's purple, its icon's last colour, Todo's strawberry, Train's carrot and Meditate's fern, their screens' wash. For trying it on a phone, a debug build's Settings has a section Navigation bar (debug), halkyon_ui's NavigationBarDebugSection, switching live, in memory alone: the variant (A Material only, C the wave with Material's fill, E without it, F without the indicator too), the wave's time (0.7 to 2 s) and the corners (0 to 28 dp). Release builds never read it and show shippingNavigationBarStyle, the debug section's start: to remove it, delete the section from the four Settings screens and debugNavigationBarStyle, and let effectiveNavigationBarStyle return the shipping style. Whats-New: Tapping a tab sends a soft wave in the app's colour across the navigation bar, now with rounded corners. Whats-New-De: Ein Tipp auf einen Tab schickt eine sanfte Welle in der Farbe der App über die Navigationsleiste, jetzt mit abgerundeten Ecken. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
05db5e2f45
commit
45fd6595e1
18 changed files with 789 additions and 14 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), 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 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*).
|
||||
- **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`).
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ class const AppShell({
|
|||
// The icon has a white background, which the frame's rounded tile
|
||||
// keeps reading on light and dark rails alike.
|
||||
icon: learnIcon,
|
||||
// The bar's wave and chosen tab: Learn's purple, its icon's last colour.
|
||||
accent: PaletteColor.purple.background,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onSelected: (index) {
|
||||
// Another tab keeps the assistant's field as it is (D69); the
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -287,7 +287,9 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
||||
'the label of the tab in view in the secondary colour', (tester) async {
|
||||
"the tab in view in Learn's purple, its icon's last colour", (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester, width: 400);
|
||||
|
||||
expect(tester.getSize(find.byType(NavigationBar)).height, 64);
|
||||
|
|
@ -302,7 +304,7 @@ void main() {
|
|||
DefaultTextStyle.of(tester.element(text)).style.color;
|
||||
}
|
||||
|
||||
expect(colorOf('Home'), scheme.secondary);
|
||||
expect(colorOf('Home'), PaletteColor.purple.background);
|
||||
expect(colorOf('Library'), scheme.onSurfaceVariant);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class const MeditateShell({
|
|||
child: NavigationFrame(
|
||||
appName: l10n.appTitle,
|
||||
icon: meditateIcon,
|
||||
// The bar's wave and chosen tab: Meditate's fern, its screens' wash.
|
||||
accent: PaletteColor.fern.background,
|
||||
selectedIndex: shell.currentIndex,
|
||||
// A tab chosen again goes back to its first screen.
|
||||
onSelected: (index) => shell.goBranch(
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ class _TodoShellState() extends State<TodoShell> {
|
|||
child: NavigationFrame(
|
||||
appName: l10n.appTitle,
|
||||
icon: todoIcon,
|
||||
// The bar's wave and chosen tab: Todo's strawberry, its screens' wash.
|
||||
accent: PaletteColor.strawberry.background,
|
||||
selectedIndex: shell.currentIndex,
|
||||
hidesNavigationBar: _selection.active || _hold.held,
|
||||
// A tab chosen again goes back to its first screen; choosing tasks
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// The navigation bar's looks to try on a phone, in debug
|
||||
// builds alone (halkyon_ui's).
|
||||
const NavigationBarDebugSection(),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ class const TrainShell({
|
|||
status: recording ? const RecordingBar() : null,
|
||||
appName: l10n.appTitle,
|
||||
icon: trainIcon,
|
||||
// The bar's wave and chosen tab: Train's carrot, its screens' wash.
|
||||
accent: PaletteColor.carrot.background,
|
||||
selectedIndex: selected < 0 ? 0 : selected,
|
||||
onSelected: (index) => shell.goBranch(
|
||||
branches[index],
|
||||
|
|
|
|||
|
|
@ -1490,7 +1490,11 @@ Strava. What was found, 27 Oct:
|
|||
keeps it, 64 dp in the theme's own colour across the whole width, and a fade 32 dp high
|
||||
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
|
||||
`FlushNavigationBar`, the rail unchanged). Driven by
|
||||
`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
|
||||
from it, 1.2 s, the destination chosen in that colour, without Material's ripple or
|
||||
the indicator growing, the variant E of the developer's mockup, which a debug build's
|
||||
Settings switches live among the others). Driven by
|
||||
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
||||
workout's page opens in Home and a day's in Profile with the tabs beside them, the
|
||||
route and plan editors and the recording over them; over keeping `Navigator.push`
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ LeanCode's lints.
|
|||
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
||||
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
|
||||
app's frame around its tabs (the rail with the app's icon, the bar flush
|
||||
with the foot with a fade above it, a tick on every choice, a status such as a recording under way above
|
||||
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`,
|
||||
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;
|
||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export 'src/widget/keyboard_back_on_resume.dart';
|
|||
export 'src/widget/launch_screen.dart';
|
||||
export 'src/widget/lift_while_dragged.dart';
|
||||
export 'src/widget/more_info_sheet.dart';
|
||||
export 'src/widget/navigation_bar_style.dart';
|
||||
export 'src/widget/navigation_frame.dart';
|
||||
export 'src/widget/palette_color_picker.dart';
|
||||
export 'src/widget/reorder_grip.dart';
|
||||
|
|
|
|||
|
|
@ -1,22 +1,48 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A wave over the navigation bar: which destination it spreads from, and
|
||||
/// which tap sent it (`serial` counts them, so a tap on the same destination
|
||||
/// as the last sends another).
|
||||
typedef NavigationWave = ({int index, int serial});
|
||||
|
||||
/// The navigation bar at the window's foot, flush with its edges, as
|
||||
/// Material 3 Expressive keeps it (decided 7 Nov 2026 by the developer, in
|
||||
/// place of a bar floating above the foot): [bar] across the whole width,
|
||||
/// in the theme's own colour and height (64 dp, `appTheme`), running on
|
||||
/// beneath the system's bar as Material's does. Right above it, a fade
|
||||
/// [fadeHeight] high into [background], the screen's own, so that what
|
||||
/// on the theme's own colour and in its height (64 dp, `appTheme`), running
|
||||
/// on beneath the system's bar as Material's does, its top corners rounded
|
||||
/// by [cornerRadius], the screen showing through them. Right above it, a
|
||||
/// fade [fadeHeight] high into [background], the screen's own, so that what
|
||||
/// scrolls on beneath the bar fades out rather than being cut off; taps
|
||||
/// pass through it to the screen.
|
||||
///
|
||||
/// A [wave] in [accent] spreads over the bar from the destination tapped
|
||||
/// and fades, over [waveDuration], behind the destinations.
|
||||
class const FlushNavigationBar({
|
||||
/// The bar: a [NavigationBar] as the theme draws it.
|
||||
/// The bar: a [NavigationBar] without a background or elevation of its
|
||||
/// own, which this paints beneath the wave.
|
||||
required final Widget bar,
|
||||
|
||||
/// The background of the screen in view, which the fade ends in.
|
||||
required final Color background,
|
||||
|
||||
/// The app's colour, the wave's.
|
||||
required final Color accent,
|
||||
|
||||
/// How many destinations the bar has, for where the wave starts.
|
||||
required final int destinationCount,
|
||||
|
||||
/// The latest wave; none while null.
|
||||
final NavigationWave? wave,
|
||||
|
||||
/// How long a wave takes.
|
||||
final Duration waveDuration = const Duration(milliseconds: 1200),
|
||||
|
||||
/// How round the bar's top corners are.
|
||||
final double cornerRadius = 0,
|
||||
|
||||
/// What stands above the bar while it matters (Train's recording under
|
||||
/// way), on [background], below the fade; none while null.
|
||||
final Widget? status,
|
||||
|
|
@ -28,6 +54,12 @@ class const FlushNavigationBar({
|
|||
/// The fade above the bar, for tests to find.
|
||||
static const fadeKey = Key('flush-navigation-bar-fade');
|
||||
|
||||
/// The bar's clip, which rounds its corners, for tests to find.
|
||||
static const barKey = Key('flush-navigation-bar');
|
||||
|
||||
/// The wave while it runs, for tests to find.
|
||||
static const waveKey = Key('flush-navigation-bar-wave');
|
||||
|
||||
/// How much of the window's foot the content of [context] leaves room
|
||||
/// for at its end: the system's own bar (a phone's gesture bar or
|
||||
/// buttons), and the navigation bar with its fade above it where the
|
||||
|
|
@ -48,6 +80,7 @@ class const FlushNavigationBar({
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = this.status;
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -75,8 +108,163 @@ class const FlushNavigationBar({
|
|||
child: status,
|
||||
),
|
||||
),
|
||||
bar,
|
||||
ClipRRect(
|
||||
key: barKey,
|
||||
borderRadius: cornerRadius == 0
|
||||
? BorderRadius.zero
|
||||
: BorderRadius.vertical(top: Radius.circular(cornerRadius)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
// Material 3's bar colour, as the bar would paint itself.
|
||||
color:
|
||||
NavigationBarTheme.of(context).backgroundColor ??
|
||||
theme.colorScheme.surfaceContainer,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: _WaveLayer(
|
||||
wave: wave,
|
||||
color: accent,
|
||||
duration: waveDuration,
|
||||
count: destinationCount,
|
||||
),
|
||||
),
|
||||
bar,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The wave while it runs, painted over the bar's colour and beneath its
|
||||
/// destinations; nothing between waves.
|
||||
class const _WaveLayer({
|
||||
required final NavigationWave? wave,
|
||||
required final Color color,
|
||||
required final Duration duration,
|
||||
required final int count,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_WaveLayer> createState() => _WaveLayerState();
|
||||
}
|
||||
|
||||
class _WaveLayerState()
|
||||
extends State<_WaveLayer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
)..addStatusListener((_) => setState(() {}));
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_WaveLayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_controller.duration = widget.duration;
|
||||
if (widget.wave != null && widget.wave != oldWidget.wave) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final wave = widget.wave;
|
||||
if (wave == null || !_controller.isAnimating) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final padding = MediaQuery.paddingOf(context);
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
key: FlushNavigationBar.waveKey,
|
||||
painter: _WavePainter(
|
||||
progress: _controller,
|
||||
color: widget.color,
|
||||
dark: Theme.of(context).brightness == Brightness.dark,
|
||||
index: wave.index,
|
||||
count: widget.count,
|
||||
// The bar's own height, above the system's bar.
|
||||
barHeight: NavigationBarTheme.of(context).height ?? 80,
|
||||
left: padding.left,
|
||||
right: padding.right,
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial glow from the centre of destination [index] of [count]: [color]
|
||||
/// solid to 35 % of its radius, gone at 70 %, growing to cover the bar
|
||||
/// (easing out, `cubic(.2, .7, .2, 1)`) while it fades, from half its
|
||||
/// strength in dark mode and a third in light.
|
||||
class _WavePainter({
|
||||
/// How far the wave has come, 0 to 1.
|
||||
required final Animation<double> progress,
|
||||
required final Color color,
|
||||
required final bool dark,
|
||||
required final int index,
|
||||
required final int count,
|
||||
required final double barHeight,
|
||||
required final double left,
|
||||
required final double right,
|
||||
}) extends CustomPainter {
|
||||
this : super(repaint: progress);
|
||||
|
||||
/// How the wave grows.
|
||||
static const growth = Cubic(0.2, 0.7, 0.2, 1);
|
||||
|
||||
/// How much of the radius is solid, and where it is gone.
|
||||
static const solid = 0.35;
|
||||
static const gone = 0.7;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final t = progress.value;
|
||||
final width = size.width - left - right;
|
||||
final centre = Offset(
|
||||
left + width * (index + 0.5) / math.max(count, 1),
|
||||
math.min(barHeight, size.height) / 2,
|
||||
);
|
||||
// Far enough that its edge, at [gone], passes the bar's far corner.
|
||||
final far = [
|
||||
Offset.zero,
|
||||
Offset(size.width, 0),
|
||||
Offset(0, size.height),
|
||||
Offset(size.width, size.height),
|
||||
].map((corner) => (corner - centre).distance).reduce(math.max);
|
||||
final radius = growth.transform(t) * far / gone;
|
||||
if (radius <= 0) {
|
||||
return;
|
||||
}
|
||||
final (start, middle) = dark ? (0.5, 0.32) : (0.35, 0.22);
|
||||
final opacity = t < 0.45
|
||||
? lerpDouble(start, middle, t / 0.45)!
|
||||
: lerpDouble(middle, 0, (t - 0.45) / 0.55)!;
|
||||
final shown = color.withValues(alpha: opacity);
|
||||
final paint = Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [shown, shown, color.withValues(alpha: 0)],
|
||||
stops: const [0, solid, gone],
|
||||
).createShader(Rect.fromCircle(center: centre, radius: radius));
|
||||
canvas.drawRect(Offset.zero & size, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_WavePainter oldDelegate) =>
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.dark != dark ||
|
||||
oldDelegate.index != index ||
|
||||
oldDelegate.count != count ||
|
||||
oldDelegate.barHeight != barHeight ||
|
||||
oldDelegate.left != left ||
|
||||
oldDelegate.right != right;
|
||||
}
|
||||
|
|
|
|||
172
packages/halkyon_ui/lib/src/widget/navigation_bar_style.dart
Normal file
172
packages/halkyon_ui/lib/src/widget/navigation_bar_style.dart
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_ui/src/widget/section_card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How the navigation bar below medium windows answers a tap (the variants
|
||||
/// the developer tried on the phone, 7 Nov 2026, by their letters).
|
||||
enum NavigationBarVariant(
|
||||
/// Its letter in the mockup and the debug section.
|
||||
final String letter,
|
||||
) {
|
||||
/// A: Material's bar as it is, its ripple and its indicator growing.
|
||||
material('A'),
|
||||
|
||||
/// C: a wave in the app's colour from the destination tapped, the
|
||||
/// destination chosen in that colour, Material's ripple and growing
|
||||
/// indicator kept.
|
||||
wave('C'),
|
||||
|
||||
/// E: the wave and the colour without the fill: no ripple, the indicator
|
||||
/// there at once.
|
||||
waveWithoutFill('E'),
|
||||
|
||||
/// F: as E, without the indicator.
|
||||
waveWithoutIndicator('F');
|
||||
|
||||
/// Whether a tap sends a wave and the destination chosen takes the app's
|
||||
/// colour.
|
||||
bool get waves => this != material;
|
||||
}
|
||||
|
||||
/// The navigation bar's look: its variant, how long the wave takes, how
|
||||
/// round the bar's top corners are.
|
||||
typedef NavigationBarStyle = ({
|
||||
NavigationBarVariant variant,
|
||||
Duration waveDuration,
|
||||
double cornerRadius,
|
||||
});
|
||||
|
||||
/// 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).
|
||||
const NavigationBarStyle shippingNavigationBarStyle = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: Duration(milliseconds: 1200),
|
||||
cornerRadius: 20,
|
||||
);
|
||||
|
||||
/// The look a debug build shows, switched live in Settings' debug section
|
||||
/// (`NavigationBarDebugSection`); kept in memory alone, the shipping look at
|
||||
/// every start. Release builds never read it.
|
||||
final debugNavigationBarStyle = ValueNotifier<NavigationBarStyle>(
|
||||
shippingNavigationBarStyle,
|
||||
);
|
||||
|
||||
/// The look the frame shows: in a [debug] build the debug section's, else
|
||||
/// the shipping one.
|
||||
NavigationBarStyle effectiveNavigationBarStyle({bool debug = kDebugMode}) =>
|
||||
debug ? debugNavigationBarStyle.value : shippingNavigationBarStyle;
|
||||
|
||||
/// Settings' section switching the navigation bar's look live while testing
|
||||
/// on a phone: variant, the wave's time, the corners. In debug builds
|
||||
/// 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
|
||||
/// and [debugNavigationBarStyle], and let the frame take
|
||||
/// [shippingNavigationBarStyle].
|
||||
class const NavigationBarDebugSection({
|
||||
/// Whether this is a debug build; tests pass false for a release's.
|
||||
@visibleForTesting final bool debug = kDebugMode,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The wave's times on offer.
|
||||
static const durations = [700, 900, 1200, 1600, 2000];
|
||||
|
||||
/// The corners on offer.
|
||||
static const corners = [0.0, 12.0, 20.0, 28.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!debug) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: debugNavigationBarStyle,
|
||||
builder: (context, style, _) {
|
||||
void set(NavigationBarStyle next) =>
|
||||
debugNavigationBarStyle.value = next;
|
||||
String seconds(int ms) =>
|
||||
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Navigation bar (debug)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Choice(
|
||||
label: 'Variant',
|
||||
values: NavigationBarVariant.values,
|
||||
selected: style.variant,
|
||||
name: (variant) => variant.letter,
|
||||
onSelected: (variant) => set((
|
||||
variant: variant,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
)),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Wave',
|
||||
values: durations,
|
||||
selected: style.waveDuration.inMilliseconds,
|
||||
name: seconds,
|
||||
onSelected: (ms) => set((
|
||||
variant: style.variant,
|
||||
waveDuration: Duration(milliseconds: ms),
|
||||
cornerRadius: style.cornerRadius,
|
||||
)),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Corners',
|
||||
values: corners,
|
||||
selected: style.cornerRadius,
|
||||
name: (radius) => radius.toStringAsFixed(0),
|
||||
onSelected: (radius) => set((
|
||||
variant: style.variant,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: radius,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of chips choosing one of [values].
|
||||
class const _Choice<T>({
|
||||
required final String label,
|
||||
required final List<T> values,
|
||||
required final T selected,
|
||||
required final String Function(T value) name,
|
||||
required final ValueChanged<T> onSelected,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 72, child: Text(label)),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final value in values)
|
||||
ChoiceChip(
|
||||
label: Text(name(value)),
|
||||
selected: value == selected,
|
||||
onSelected: (_) => onSelected(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_ui/src/layout/window_size.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/screen_tint.dart';
|
||||
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -31,6 +32,13 @@ typedef FrameDestination = ({
|
|||
/// system's touch feedback is on. Choosing the tab already selected is
|
||||
/// passed on too, for the app to go back to its first screen, as Material
|
||||
/// prescribes.
|
||||
///
|
||||
/// The bar's look is [shippingNavigationBarStyle] (the developer's choice,
|
||||
/// 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
|
||||
/// from it, unless animations are off, and the destination chosen takes
|
||||
/// [accent]; a tab changed otherwise (a link, back) sends none, as the wave
|
||||
/// answers the finger.
|
||||
class const NavigationFrame({
|
||||
/// The tabs, in order.
|
||||
required final List<FrameDestination> destinations,
|
||||
|
|
@ -47,6 +55,11 @@ class const NavigationFrame({
|
|||
/// The app's icon, square, atop the rail.
|
||||
required final ImageProvider icon,
|
||||
|
||||
/// 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
|
||||
/// wave's and the destination chosen's on the bar.
|
||||
required final Color accent,
|
||||
|
||||
/// The tab's screens.
|
||||
required final Widget child,
|
||||
|
||||
|
|
@ -70,14 +83,33 @@ class const NavigationFrame({
|
|||
class _NavigationFrameState() extends State<NavigationFrame> {
|
||||
final _tint = ScreenTint();
|
||||
|
||||
/// The latest wave sent by a tap; none before one.
|
||||
NavigationWave? _wave;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugNavigationBarStyle.addListener(_restyle);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
debugNavigationBarStyle.removeListener(_restyle);
|
||||
_tint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _restyle() => setState(() {});
|
||||
|
||||
void _select(int index) {
|
||||
HapticFeedback.selectionClick();
|
||||
if (index != widget.selectedIndex &&
|
||||
effectiveNavigationBarStyle().variant.waves &&
|
||||
!MediaQuery.disableAnimationsOf(context)) {
|
||||
setState(
|
||||
() => _wave = (index: index, serial: (_wave?.serial ?? 0) + 1),
|
||||
);
|
||||
}
|
||||
widget.onSelected(index);
|
||||
}
|
||||
|
||||
|
|
@ -121,9 +153,28 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
),
|
||||
);
|
||||
}
|
||||
final bar = NavigationBar(
|
||||
final style = effectiveNavigationBarStyle();
|
||||
final variant = style.variant;
|
||||
final accent = widget.accent;
|
||||
final withoutFill =
|
||||
variant == NavigationBarVariant.waveWithoutFill ||
|
||||
variant == NavigationBarVariant.waveWithoutIndicator;
|
||||
final Widget bar = NavigationBar(
|
||||
// The flush bar paints the bar's colour beneath the wave.
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: _select,
|
||||
indicatorColor: switch (variant) {
|
||||
NavigationBarVariant.material => null,
|
||||
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
|
||||
_ => accent.withValues(alpha: 0.22),
|
||||
},
|
||||
// Without the fill: no ripple, the indicator there at once.
|
||||
overlayColor: withoutFill
|
||||
? const WidgetStatePropertyAll(Colors.transparent)
|
||||
: null,
|
||||
animationDuration: withoutFill ? Duration.zero : null,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationDestination(
|
||||
|
|
@ -172,8 +223,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
)
|
||||
: FlushNavigationBar(
|
||||
background: background ?? surface,
|
||||
accent: accent,
|
||||
destinationCount: destinations.length,
|
||||
wave: _wave,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
status: widget.status,
|
||||
bar: bar,
|
||||
bar: variant.waves
|
||||
? _Chosen(accent: accent, child: bar)
|
||||
: bar,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -182,6 +240,33 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The bar's destination chosen in [accent], its icon and its label, the
|
||||
/// others as the theme has them.
|
||||
class const _Chosen({required final Color accent, required final Widget child})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = NavigationBarTheme.of(context);
|
||||
return NavigationBarTheme(
|
||||
data: theme.copyWith(
|
||||
iconTheme: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? IconThemeData(color: accent, size: 24)
|
||||
: theme.iconTheme?.resolve(states),
|
||||
),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? (theme.labelTextStyle?.resolve(states) ??
|
||||
Theme.of(context).textTheme.labelMedium)
|
||||
?.copyWith(color: accent)
|
||||
: theme.labelTextStyle?.resolve(states),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's icon atop the rail, leading to the first tab ([onTap], named
|
||||
/// by [first]) as a website's logo does. In a rounded tile, so that an icon
|
||||
/// with a background of its own reads on light and dark rails alike.
|
||||
|
|
|
|||
288
packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart
Normal file
288
packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
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 bar's look below medium windows (the developer's choice of 7 Nov
|
||||
/// 2026): a wave in the app's colour spreading from the destination tapped,
|
||||
/// the destination chosen in that colour, no fill, rounded top corners; and
|
||||
/// the section switching them while testing, in debug builds alone.
|
||||
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,
|
||||
]),
|
||||
);
|
||||
|
||||
const accent = Color(0xFFEC3E7C);
|
||||
|
||||
tearDown(() => debugNavigationBarStyle.value = shippingNavigationBarStyle);
|
||||
|
||||
/// The frame on tab [selected], its choices passed to [onSelected];
|
||||
/// animations off where [disableAnimations].
|
||||
Widget frame({
|
||||
required int selected,
|
||||
ValueChanged<int>? onSelected,
|
||||
bool disableAnimations = false,
|
||||
}) => testApp(
|
||||
Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(disableAnimations: disableAnimations),
|
||||
child: NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: accent,
|
||||
selectedIndex: selected,
|
||||
onSelected: onSelected ?? (_) {},
|
||||
destinations: const [
|
||||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||
(label: 'Profile', icon: Icon(Icons.person), selectedIcon: null),
|
||||
],
|
||||
child: const Scaffold(body: Text('The tab')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// A frame whose tab follows the taps on its bar.
|
||||
Widget following({bool disableAnimations = false}) {
|
||||
var selected = 0;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) => frame(
|
||||
selected: selected,
|
||||
disableAnimations: disableAnimations,
|
||||
onSelected: (index) => setState(() => selected = index),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> phone(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
final wave = find.byKey(FlushNavigationBar.waveKey);
|
||||
|
||||
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 {
|
||||
expect(shippingNavigationBarStyle, (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
));
|
||||
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
|
||||
});
|
||||
|
||||
testWidgets('a tap on another destination sends a wave over the bar, gone '
|
||||
'once its time is up', (tester) async {
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(following());
|
||||
expect(wave, findsNothing);
|
||||
|
||||
await tester.tap(find.text('Profile'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(wave, findsOneWidget);
|
||||
final bar = tester.getRect(find.byType(NavigationBar));
|
||||
expect(
|
||||
tester.getRect(wave),
|
||||
bar,
|
||||
reason: 'it covers the bar, clipped to it',
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the wave takes as long as the style says', (tester) async {
|
||||
await phone(tester);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 20.0,
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 1500));
|
||||
expect(wave, findsOneWidget);
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('no wave for the destination already chosen, nor with '
|
||||
'animations off', (tester) async {
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(wave, findsNothing, reason: 'the tab already chosen');
|
||||
|
||||
await tester.pumpWidget(following(disableAnimations: true));
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(wave, findsNothing, reason: 'animations off');
|
||||
});
|
||||
|
||||
testWidgets('a tab changed without a tap (a link, back) sends no wave: '
|
||||
'the wave answers the finger', (tester) async {
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(frame(selected: 0));
|
||||
await tester.pumpWidget(frame(selected: 2));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Material alone sends no wave', (tester) async {
|
||||
await phone(tester);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(wave, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the destination chosen is in the app colour, its indicator '
|
||||
'tinted with it; without the fill no ripple and no growing indicator; '
|
||||
'the last variant without the indicator', (tester) async {
|
||||
await phone(tester);
|
||||
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
|
||||
Color? iconColor(IconData data) => IconTheme.of(
|
||||
tester.element(find.byIcon(data)),
|
||||
).color;
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.wave,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
);
|
||||
await tester.pumpWidget(frame(selected: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), accent);
|
||||
expect(iconColor(Icons.home), isNot(accent));
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||||
accent,
|
||||
);
|
||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||
expect(bar().overlayColor, isNull, reason: 'with the fill');
|
||||
|
||||
debugNavigationBarStyle.value = shippingNavigationBarStyle;
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), accent);
|
||||
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
|
||||
expect(
|
||||
bar().overlayColor?.resolve({WidgetState.pressed}),
|
||||
Colors.transparent,
|
||||
);
|
||||
expect(bar().animationDuration, Duration.zero);
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), accent);
|
||||
expect(bar().indicatorColor, Colors.transparent);
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), isNot(accent));
|
||||
expect(bar().indicatorColor, isNull);
|
||||
});
|
||||
|
||||
testWidgets("the bar's top corners are rounded as the style says, the "
|
||||
'screen showing through them', (tester) async {
|
||||
await phone(tester);
|
||||
BorderRadiusGeometry corners() => tester
|
||||
.widget<ClipRRect>(find.byKey(FlushNavigationBar.barKey))
|
||||
.borderRadius;
|
||||
|
||||
await tester.pumpWidget(frame(selected: 0));
|
||||
expect(corners(), const BorderRadius.vertical(top: Radius.circular(20)));
|
||||
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 0.0,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(corners(), BorderRadius.zero);
|
||||
});
|
||||
|
||||
testWidgets('the debug section switches the look live, and shows nothing '
|
||||
'outside debug builds', (tester) async {
|
||||
await phone(tester);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(
|
||||
body: SingleChildScrollView(child: NavigationBarDebugSection()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Navigation bar (debug)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('F'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('2 s'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('28'));
|
||||
await tester.pump();
|
||||
expect(debugNavigationBarStyle.value, (
|
||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 28.0,
|
||||
));
|
||||
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(body: NavigationBarDebugSection(debug: false)),
|
||||
),
|
||||
);
|
||||
expect(find.text('Navigation bar (debug)'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('outside debug builds the frame keeps the shipping look '
|
||||
'whatever the debug section holds', (tester) async {
|
||||
expect(
|
||||
effectiveNavigationBarStyle(debug: false),
|
||||
shippingNavigationBarStyle,
|
||||
);
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 0.0,
|
||||
);
|
||||
expect(
|
||||
effectiveNavigationBarStyle(debug: false),
|
||||
shippingNavigationBarStyle,
|
||||
);
|
||||
// Tests run as a debug build.
|
||||
expect(effectiveNavigationBarStyle(), debugNavigationBarStyle.value);
|
||||
});
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: selected,
|
||||
onSelected: chosen.add,
|
||||
destinations: const [
|
||||
|
|
@ -97,8 +98,14 @@ void main() {
|
|||
tester.getRect(bar),
|
||||
const Rect.fromLTRB(0, 800 - 64 - 24, 400, 800),
|
||||
);
|
||||
expect(tester.widget<NavigationBar>(bar).backgroundColor, isNull);
|
||||
expect(tester.widget<NavigationBar>(bar).elevation, isNull);
|
||||
final colour = find.descendant(
|
||||
of: find.byKey(FlushNavigationBar.barKey),
|
||||
matching: find.byType(ColoredBox),
|
||||
);
|
||||
expect(
|
||||
tester.widget<ColoredBox>(colour.first).color,
|
||||
Theme.of(tester.element(bar)).colorScheme.surfaceContainer,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('right above the bar, a fade into the background of the screen '
|
||||
|
|
@ -115,6 +122,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
destinations: const [
|
||||
|
|
@ -162,6 +170,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
destinations: const [
|
||||
|
|
@ -198,6 +207,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
destinations: const [
|
||||
|
|
@ -329,6 +339,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
hidesNavigationBar: true,
|
||||
|
|
@ -356,6 +367,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
status: const Text('Recording'),
|
||||
|
|
@ -391,6 +403,7 @@ void main() {
|
|||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
accent: const Color(0xFFEC3E7C),
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
destinations: const [
|
||||
|
|
|
|||
Loading…
Reference in a new issue