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
8 changed files with 549 additions and 144 deletions
|
|
@ -30,7 +30,9 @@ LeanCode's lints.
|
|||
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, 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
|
||||
the bar), whichever router drives it;
|
||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export 'src/theme/app_fonts.dart';
|
|||
export 'src/theme/app_theme.dart';
|
||||
export 'src/theme/brand.dart';
|
||||
export 'src/theme/palette_color.dart';
|
||||
export 'src/theme/readable_tone.dart';
|
||||
export 'src/theme/theme_extras.dart';
|
||||
export 'src/widget/adaptive_sheet.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.
|
||||
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,
|
||||
|
||||
/// 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.
|
||||
required final int destinationCount,
|
||||
|
||||
|
|
@ -80,7 +84,6 @@ 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,
|
||||
|
|
@ -116,12 +119,7 @@ class const FlushNavigationBar({
|
|||
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,
|
||||
),
|
||||
child: ColoredBox(color: surface),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: _WaveLayer(
|
||||
|
|
|
|||
|
|
@ -29,19 +29,23 @@ enum NavigationBarVariant(
|
|||
}
|
||||
|
||||
/// 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 = ({
|
||||
NavigationBarVariant variant,
|
||||
Duration waveDuration,
|
||||
double cornerRadius,
|
||||
bool tintedSurface,
|
||||
});
|
||||
|
||||
/// 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 = (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: Duration(milliseconds: 1200),
|
||||
cornerRadius: 20,
|
||||
tintedSurface: false,
|
||||
);
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
/// it before About; to remove it, delete the section from the apps' Settings
|
||||
/// and [debugNavigationBarStyle], and let the frame take
|
||||
|
|
@ -81,8 +86,17 @@ class const NavigationBarDebugSection({
|
|||
return ValueListenableBuilder(
|
||||
valueListenable: debugNavigationBarStyle,
|
||||
builder: (context, style, _) {
|
||||
void set(NavigationBarStyle next) =>
|
||||
debugNavigationBarStyle.value = next;
|
||||
void set({
|
||||
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) =>
|
||||
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
|
||||
return Padding(
|
||||
|
|
@ -101,33 +115,29 @@ class const NavigationBarDebugSection({
|
|||
values: NavigationBarVariant.values,
|
||||
selected: style.variant,
|
||||
name: (variant) => variant.letter,
|
||||
onSelected: (variant) => set((
|
||||
variant: variant,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
)),
|
||||
onSelected: (variant) => set(variant: variant),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Wave',
|
||||
values: durations,
|
||||
selected: style.waveDuration.inMilliseconds,
|
||||
name: seconds,
|
||||
onSelected: (ms) => set((
|
||||
variant: style.variant,
|
||||
waveDuration: Duration(milliseconds: ms),
|
||||
cornerRadius: style.cornerRadius,
|
||||
)),
|
||||
onSelected: (ms) =>
|
||||
set(waveDuration: Duration(milliseconds: ms)),
|
||||
),
|
||||
_Choice(
|
||||
label: 'Corners',
|
||||
values: corners,
|
||||
selected: style.cornerRadius,
|
||||
name: (radius) => radius.toStringAsFixed(0),
|
||||
onSelected: (radius) => set((
|
||||
variant: style.variant,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: radius,
|
||||
)),
|
||||
onSelected: (radius) => set(cornerRadius: radius),
|
||||
),
|
||||
_Choice(
|
||||
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: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/navigation_bar_style.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,
|
||||
/// 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.
|
||||
/// to: a tap on another destination sends a wave over the bar from it,
|
||||
/// unless animations are off, and the destination chosen takes the colour
|
||||
/// of the screen in view ([ScreenTint.color], [accent] until a screen
|
||||
/// claims one), legible on the bar; 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,
|
||||
|
|
@ -56,8 +58,9 @@ class const NavigationFrame({
|
|||
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.
|
||||
/// purple, Todo's strawberry, Train's carrot, Meditate's fern): the bar's
|
||||
/// until a screen claims a colour of its own ([ScreenTint.color]), which
|
||||
/// the bar takes from then on.
|
||||
required final Color accent,
|
||||
|
||||
/// The tab's screens.
|
||||
|
|
@ -155,10 +158,74 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
}
|
||||
final style = effectiveNavigationBarStyle();
|
||||
final variant = style.variant;
|
||||
final accent = widget.accent;
|
||||
final withoutFill =
|
||||
variant == NavigationBarVariant.waveWithoutFill ||
|
||||
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(
|
||||
// The flush bar paints the bar's colour beneath the wave.
|
||||
backgroundColor: Colors.transparent,
|
||||
|
|
@ -168,7 +235,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
indicatorColor: switch (variant) {
|
||||
NavigationBarVariant.material => null,
|
||||
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
|
||||
_ => accent.withValues(alpha: 0.22),
|
||||
_ => indicator,
|
||||
},
|
||||
// Without the fill: no ripple, the indicator there at once.
|
||||
overlayColor: withoutFill
|
||||
|
|
@ -184,58 +251,58 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
),
|
||||
],
|
||||
);
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
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,
|
||||
// The screens run on beneath the bar's fade and the bar, fading
|
||||
// out above it; their lists end with room for both (the body's
|
||||
// bottom padding says how much, FlushNavigationBar.coverOf).
|
||||
extendBody: !widget.hidesNavigationBar,
|
||||
// The keyboard is each screen's own to make way for, its own
|
||||
// scaffold resizing as it does by default: a screen with a field
|
||||
// floating over it keeps its size and lifts the field instead
|
||||
// (Halkyon Learn's ADR 0021, D69).
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: widget.child,
|
||||
// Without the bar, a strip as high as the system's own bar, in
|
||||
// the screen's background, keeps every screen inside clear of
|
||||
// it.
|
||||
bottomNavigationBar: widget.hidesNavigationBar
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.status case final status?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: status,
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
)
|
||||
: FlushNavigationBar(
|
||||
background: background ?? surface,
|
||||
accent: accent,
|
||||
destinationCount: destinations.length,
|
||||
wave: _wave,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
status: widget.status,
|
||||
bar: variant.waves
|
||||
? _Chosen(accent: accent, child: bar)
|
||||
: bar,
|
||||
return Scaffold(
|
||||
backgroundColor: background,
|
||||
// The screens run on beneath the bar's fade and the bar, fading out
|
||||
// above it; their lists end with room for both (the body's bottom
|
||||
// padding says how much, FlushNavigationBar.coverOf).
|
||||
extendBody: !widget.hidesNavigationBar,
|
||||
// The keyboard is each screen's own to make way for, its own scaffold
|
||||
// resizing as it does by default: a screen with a field floating over
|
||||
// it keeps its size and lifts the field instead (Halkyon Learn's ADR
|
||||
// 0021, D69).
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: widget.child,
|
||||
// Without the bar, a strip as high as the system's own bar, in the
|
||||
// screen's background, keeps every screen inside clear of it.
|
||||
bottomNavigationBar: widget.hidesNavigationBar
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.status case final status?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: status,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
)
|
||||
: FlushNavigationBar(
|
||||
background: background,
|
||||
surface: barSurface,
|
||||
accent: colour,
|
||||
destinationCount: destinations.length,
|
||||
wave: _wave,
|
||||
waveDuration: style.waveDuration,
|
||||
cornerRadius: style.cornerRadius,
|
||||
status: widget.status,
|
||||
bar: variant.waves
|
||||
? _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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
|
||||
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, (
|
||||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
));
|
||||
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
|
||||
});
|
||||
|
|
@ -112,6 +114,7 @@ void main() {
|
|||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
|
|
@ -153,6 +156,7 @@ void main() {
|
|||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpWidget(following());
|
||||
await tester.tap(find.text('Maps'));
|
||||
|
|
@ -161,60 +165,80 @@ void main() {
|
|||
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;
|
||||
testWidgets(
|
||||
'the destination chosen is in the app colour, legible, 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;
|
||||
// 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 = (
|
||||
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 = (
|
||||
variant: NavigationBarVariant.wave,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpWidget(frame(selected: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
expect(iconColor(Icons.home), isNot(tone()));
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||||
tone(),
|
||||
);
|
||||
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 = shippingNavigationBarStyle;
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
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.waveWithoutIndicator,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), tone());
|
||||
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);
|
||||
});
|
||||
debugNavigationBarStyle.value = (
|
||||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 20.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor(Icons.map), isNot(tone()));
|
||||
expect(bar().indicatorColor, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("the bar's top corners are rounded as the style says, the "
|
||||
'screen showing through them', (tester) async {
|
||||
|
|
@ -230,6 +254,7 @@ void main() {
|
|||
variant: NavigationBarVariant.waveWithoutFill,
|
||||
waveDuration: const Duration(milliseconds: 1200),
|
||||
cornerRadius: 0.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(corners(), BorderRadius.zero);
|
||||
|
|
@ -253,10 +278,13 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.tap(find.text('28'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Tinted'));
|
||||
await tester.pump();
|
||||
expect(debugNavigationBarStyle.value, (
|
||||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 28.0,
|
||||
tintedSurface: true,
|
||||
));
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -277,6 +305,7 @@ void main() {
|
|||
variant: NavigationBarVariant.material,
|
||||
waveDuration: const Duration(seconds: 2),
|
||||
cornerRadius: 0.0,
|
||||
tintedSurface: false,
|
||||
);
|
||||
expect(
|
||||
effectiveNavigationBarStyle(debug: false),
|
||||
|
|
|
|||
Loading…
Reference in a new issue