feat(ui): every app's frame around its tabs, Learn's moved into halkyon_ui
NavigationFrame is Learn's AppShell without its router and its tabs: a navigation rail from medium windows on with the app's icon on top, leading to the first tab, a bar floating above the foot below, the background of the screen in view around it, and a tick on every choice. TabsStack is Learn's container of the tabs' navigators, every tab kept alive and told whether it shows. Learn's shell is built on both, its tests unchanged; Train's is next. The rail's icon is drawn from 120 px at most in memory, as Learn's was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
57262ebf73
commit
b45db6ea7c
6 changed files with 444 additions and 171 deletions
|
|
@ -27,12 +27,14 @@ LeanCode's lints.
|
|||
`readableContentMaxWidth`.
|
||||
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
|
||||
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
|
||||
and `TabVisibility`; `FloatingNavigationBar`; `AppLoadingIndicator`,
|
||||
`ButtonLoadingIndicator`, `AppRefreshIndicator`; `SectionCard`,
|
||||
`SliverCentered`, `EmptyListMessage`, `CountBadge`, `TitleWithKicker`,
|
||||
`CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`, `ReorderToggle`,
|
||||
`ReorderGrip`; `ChevronField`, `requiredDecoration`, `FieldInfoButton`,
|
||||
`CountStepper`, `PaletteColorPicker`, `TextLimits` with
|
||||
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
|
||||
app's frame around its tabs (the rail with the app's icon, the floating
|
||||
bar, a tick on every choice), whichever router drives it;
|
||||
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
|
||||
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
|
||||
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,
|
||||
`ReorderToggle`, `ReorderGrip`; `ChevronField`, `requiredDecoration`,
|
||||
`FieldInfoButton`, `CountStepper`, `PaletteColorPicker`, `TextLimits` with
|
||||
`nearLimitCounter`; `confirmDestructiveAction`, `showUndoSnackBar`,
|
||||
`showMoreInfoSheet`; `FoundById`; `KeyboardBackOnResume`; `BrandWordmark`
|
||||
and `LaunchScreen`, which take the app's word and icon.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,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_frame.dart';
|
||||
export 'src/widget/palette_color_picker.dart';
|
||||
export 'src/widget/reorder_grip.dart';
|
||||
export 'src/widget/reorder_toggle.dart';
|
||||
|
|
|
|||
189
packages/halkyon_ui/lib/src/widget/navigation_frame.dart
Normal file
189
packages/halkyon_ui/lib/src/widget/navigation_frame.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_ui/src/layout/window_size.dart';
|
||||
import 'package:halkyon_ui/src/widget/floating_navigation_bar.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';
|
||||
|
||||
/// A tab of a [NavigationFrame]: its label and icons, built by the app (a
|
||||
/// badge around one); its selected icon its icon where null.
|
||||
typedef FrameDestination = ({
|
||||
String label,
|
||||
Widget icon,
|
||||
Widget? selectedIcon,
|
||||
});
|
||||
|
||||
/// The frame around an app's tabs, every Halkyon app's (decided 2026-10-30,
|
||||
/// from Halkyon Learn's): a navigation rail at the side from medium windows
|
||||
/// on, the app's [icon] atop it leading to the first tab as a website's logo
|
||||
/// does, and below that a bar floating above the bottom
|
||||
/// ([FloatingNavigationBar]), the background of the screen in view around it
|
||||
/// ([ScreenTint]).
|
||||
///
|
||||
/// From medium windows on, the window takes the colour of the screen in view
|
||||
/// ([ScreenTint], as Vaia does): the rail sits on it, and the screen is a
|
||||
/// rounded panel inset on it, with its own wash at the top. Below that, each
|
||||
/// screen washes its top itself.
|
||||
///
|
||||
/// Every choice ticks before [onSelected]: the lightest haptic the platform
|
||||
/// has (a clock tick on Android), where the device has a motor and the
|
||||
/// 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.
|
||||
class const NavigationFrame({
|
||||
/// The tabs, in order.
|
||||
required final List<FrameDestination> destinations,
|
||||
|
||||
/// The tab showing.
|
||||
required final int selectedIndex,
|
||||
|
||||
/// Called with the tab chosen, after the tick.
|
||||
required final ValueChanged<int> onSelected,
|
||||
|
||||
/// The app's name, as its icon atop the rail is read out.
|
||||
required final String appName,
|
||||
|
||||
/// The app's icon, square, atop the rail.
|
||||
required final ImageProvider icon,
|
||||
|
||||
/// The tab's screens.
|
||||
required final Widget child,
|
||||
|
||||
/// Whether the bar below medium windows steps aside, for a screen with a
|
||||
/// bar of its own at the bottom: Material 3 puts the navigation bar on the
|
||||
/// top-level screens and the actions of those below in a toolbar, never
|
||||
/// both at once (as Vaia does). The rail of wider windows stays.
|
||||
final bool hidesNavigationBar = false,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<NavigationFrame> createState() => _NavigationFrameState();
|
||||
}
|
||||
|
||||
class _NavigationFrameState() extends State<NavigationFrame> {
|
||||
final _tint = ScreenTint();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.onSelected(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final destinations = widget.destinations;
|
||||
if (context.windowSize.isAtLeastMedium) {
|
||||
return ScreenTintScope(
|
||||
notifier: _tint,
|
||||
child: TintFrame(
|
||||
tint: _tint,
|
||||
rail: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
onDestinationSelected: _select,
|
||||
leading: _RailIcon(
|
||||
icon: widget.icon,
|
||||
appName: widget.appName,
|
||||
first: destinations.first.label,
|
||||
onTap: () => _select(0),
|
||||
),
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
final bar = FloatingNavigationBar(
|
||||
bar: NavigationBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: _select,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationDestination(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
label: destination.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
return ScreenTintScope(
|
||||
notifier: _tint,
|
||||
// Around 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 floating bar, showing around
|
||||
// it, as iOS 26's tab bar floats over content; their lists end
|
||||
// with room for it (the body's bottom padding says how much).
|
||||
extendBody: !widget.hidesNavigationBar,
|
||||
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
|
||||
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
|
||||
: bar,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
class const _RailIcon({
|
||||
required final ImageProvider icon,
|
||||
required final String appName,
|
||||
required final String first,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Tooltip(
|
||||
message: first,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: Image(
|
||||
// The icons are 1024 px square; a 40 dp logo needs no more
|
||||
// than this in memory, even at three times the density.
|
||||
image: ResizeImage(icon, width: 120),
|
||||
width: 40,
|
||||
height: 40,
|
||||
semanticLabel: appName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:halkyon_ui/src/widget/navigation_frame.dart'
|
||||
show NavigationFrame;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Whether the tab a screen is in is the one showing. An app whose tabs stay
|
||||
|
|
@ -21,3 +23,31 @@ class const TabVisibility({
|
|||
bool updateShouldNotify(TabVisibility oldWidget) =>
|
||||
visible != oldWidget.visible;
|
||||
}
|
||||
|
||||
/// The tabs of a [NavigationFrame]'s app, as go_router's indexed stack builds
|
||||
/// them for a `StatefulShellRoute`: every tab stays alive, the hidden ones
|
||||
/// offstage with their animations paused, and each learns from
|
||||
/// [TabVisibility] whether it shows.
|
||||
class const TabsStack({
|
||||
/// The tab showing.
|
||||
required final int index,
|
||||
|
||||
/// The tabs, in order.
|
||||
required final List<Widget> children,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => IndexedStack(
|
||||
index: index,
|
||||
children: [
|
||||
for (final (i, tab) in children.indexed)
|
||||
Offstage(
|
||||
offstage: i != index,
|
||||
child: TickerMode(
|
||||
enabled: i == index,
|
||||
child: TabVisibility(visible: i == index, child: tab),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
163
packages/halkyon_ui/test/widget/navigation_frame_test.dart
Normal file
163
packages/halkyon_ui/test/widget/navigation_frame_test.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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';
|
||||
|
||||
/// Every Halkyon app's frame around its tabs: a rail with the app's icon
|
||||
/// from medium windows on, a floating bar below, a tick on every choice.
|
||||
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,
|
||||
]),
|
||||
);
|
||||
|
||||
/// The frame in a window [width] wide, on tab [selected]; the tabs chosen
|
||||
/// go into [chosen].
|
||||
Future<void> show(
|
||||
WidgetTester tester, {
|
||||
required double width,
|
||||
required List<int> chosen,
|
||||
int selected = 1,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
selectedIndex: selected,
|
||||
onSelected: chosen.add,
|
||||
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')),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The ticks the platform was asked for while [act] ran.
|
||||
Future<List<Object?>> ticks(
|
||||
WidgetTester tester,
|
||||
Future<void> Function() act,
|
||||
) async {
|
||||
final haptics = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
haptics.add(call.arguments);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await act();
|
||||
return haptics;
|
||||
}
|
||||
|
||||
testWidgets('below medium windows, a bar floats above the foot', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, width: 400, chosen: []);
|
||||
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.text('The tab'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("from medium windows on, a rail with the app's icon on top, "
|
||||
'which leads to the first tab', (tester) async {
|
||||
final chosen = <int>[];
|
||||
await show(tester, width: 800, chosen: chosen);
|
||||
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.bySemanticsLabel('Halkyon Test'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Home'));
|
||||
await tester.pump();
|
||||
expect(chosen, [0]);
|
||||
});
|
||||
|
||||
testWidgets('every choice on the bar ticks, lightly', (tester) async {
|
||||
final chosen = <int>[];
|
||||
await show(tester, width: 400, chosen: chosen);
|
||||
final haptics = await ticks(tester, () async {
|
||||
await tester.tap(find.text('Profile'));
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
expect(haptics, ['HapticFeedbackType.selectionClick']);
|
||||
expect(chosen, [2]);
|
||||
});
|
||||
|
||||
testWidgets('every choice on the rail ticks, lightly', (tester) async {
|
||||
final chosen = <int>[];
|
||||
await show(tester, width: 800, chosen: chosen);
|
||||
final haptics = await ticks(tester, () async {
|
||||
await tester.tap(find.text('Maps'));
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
expect(haptics, ['HapticFeedbackType.selectionClick']);
|
||||
expect(chosen, [1]);
|
||||
});
|
||||
|
||||
testWidgets('the window crossing from compact to medium and back swaps '
|
||||
'the bar for the rail', (tester) async {
|
||||
await show(tester, width: 400, chosen: []);
|
||||
for (final width in const [800.0, 400.0]) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
expect(find.text('The tab'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the bar steps aside where a screen has a bar of its own, '
|
||||
"leaving the system's bar its room", (tester) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
NavigationFrame(
|
||||
appName: 'Halkyon Test',
|
||||
icon: icon,
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
hidesNavigationBar: true,
|
||||
destinations: const [
|
||||
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
|
||||
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
|
||||
],
|
||||
child: const Scaffold(body: Text('The tab')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
|
||||
});
|
||||
}
|
||||
|
|
@ -61,4 +61,23 @@ void main() {
|
|||
|
||||
expect(visible, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the tabs stack shows the tab chosen, keeps the others alive, '
|
||||
'and tells each whether it shows', (tester) async {
|
||||
final visible = <String, bool>{};
|
||||
Widget tab(String name) => Builder(
|
||||
builder: (context) {
|
||||
visible[name] = TabVisibility.of(context);
|
||||
return Text(name);
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
testApp(TabsStack(index: 1, children: [tab('Home'), tab('Maps')])),
|
||||
);
|
||||
|
||||
expect(find.text('Maps'), findsOneWidget);
|
||||
expect(find.text('Home'), findsNothing, reason: 'offstage');
|
||||
expect(find.text('Home', skipOffstage: false), findsOneWidget);
|
||||
expect(visible, {'Home': false, 'Maps': true});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue