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:
Tizian 「ティツィアーン」 2026-10-30 23:52:39 +01:00
commit b45db6ea7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 444 additions and 171 deletions

View 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);
});
}

View file

@ -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});
});
}