feat: the rail opens into a sidebar with labels from expanded windows on

Decided by the developer on 9 and 10 Nov 2026: phones keep the bar,
medium windows (600 to 840 dp) the rail alone, and from 840 dp a toggle
at the rail's foot opens it into a sidebar 256 dp wide, as Proton
Drive's: the app named beside its icon, each tab its label beside its
icon, the icons where the rail had them, so nothing moves under the
pointer. Its labels come in one after another, top to bottom, all at once
where animations are off. Open or closed is kept per device, in
halkyon_app's Look (look.json, `sidebar`), which Learn keeps in its
DeviceSettings (`sidebarOpen`), as it does the appearance and language.

NavigationFrame takes sidebarOpen and onSidebarChanged (no toggle without
it), and sidebarSections, what the app lists below the tabs, which Todo's
favourites and projects will be. The closed rail keeps Flutter's widget,
its toggle pinned at the foot (trailingAtBottom); the open one is the
frame's own, as the staggered labels need. Learn, Todo, Train and
Meditate keep the choice.

Whats-New: In wide windows, the button at the foot of the side rail opens it into a sidebar with names, and it stays open.
Whats-New-De: In breiten Fenstern öffnet der Knopf unten an der Seitenleiste sie mit Namen, und sie bleibt offen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 16:44:08 +01:00
commit 3f441cbc98
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,245 @@
import 'dart:typed_data';
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 rail opening into a sidebar with labels (decided by the developer,
/// 9 and 10 Nov 2026): phones keep the bar, medium windows a rail alone,
/// and from expanded windows on a toggle at the rail's foot opens it into a
/// sidebar 256 wide, kept open or closed as the app keeps it, its labels
/// coming in one after another, all at once without animations.
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, the sidebar [open], its toggles
/// going into [toggled] (none offered where null), the tabs chosen into
/// [chosen].
Future<void> show(
WidgetTester tester, {
required double width,
bool open = false,
List<bool>? toggled,
List<int>? chosen,
Widget? sections,
bool still = false,
bool settle = true,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final frame = NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (index) => chosen?.add(index),
sidebarOpen: open,
onSidebarChanged: toggled?.add,
sidebarSections: sections,
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')),
);
await tester.pumpWidget(
testApp(
still
? Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: true),
child: frame,
),
)
: frame,
),
);
if (settle) {
await tester.pumpAndSettle();
} else {
await tester.pump();
}
}
testWidgets('medium windows keep the rail alone: no toggle, open or not', (
tester,
) async {
await show(tester, width: 700, open: true, toggled: []);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byKey(NavigationFrame.sidebarKey), findsNothing);
expect(find.byTooltip('Open sidebar'), findsNothing);
expect(find.byTooltip('Close sidebar'), findsNothing);
});
testWidgets('from expanded windows on, a toggle at the foot of the rail '
'asks for the sidebar', (tester) async {
final toggled = <bool>[];
await show(tester, width: 1000, toggled: toggled);
final toggle = find.byTooltip('Open sidebar');
expect(toggle, findsOneWidget);
expect(
tester.getBottomLeft(toggle).dy,
greaterThan(tester.getBottomLeft(find.text('Profile')).dy + 200),
reason: 'at the foot, apart from the tabs',
);
await tester.tap(toggle);
await tester.pump();
expect(toggled, [true]);
});
testWidgets('an app that keeps no choice offers no toggle', (tester) async {
await show(tester, width: 1000);
expect(find.byTooltip('Open sidebar'), findsNothing);
});
testWidgets('open, a sidebar 256 wide: the app named beside its icon, '
'each tab its label beside its icon, what the app lists below them, '
'and a toggle closing it', (tester) async {
final toggled = <bool>[];
final chosen = <int>[];
await show(
tester,
width: 1000,
open: true,
toggled: toggled,
chosen: chosen,
sections: const Text('Favourites'),
);
final sidebar = find.byKey(NavigationFrame.sidebarKey);
expect(tester.getSize(sidebar).width, 256);
expect(find.byType(NavigationRail), findsNothing);
expect(find.text('Halkyon Test'), findsOneWidget);
final mapsIcon = tester.getRect(find.byIcon(Icons.map));
final mapsLabel = tester.getRect(find.text('Maps'));
expect(mapsLabel.left, greaterThan(mapsIcon.right));
expect(mapsLabel.center.dy, closeTo(mapsIcon.center.dy, 1));
expect(
find.descendant(of: sidebar, matching: find.text('Favourites')),
findsOneWidget,
);
await tester.tap(find.text('Maps'));
await tester.pump();
expect(chosen, [1]);
await tester.tap(find.byTooltip('Close sidebar'));
await tester.pump();
expect(toggled, [false]);
});
testWidgets('its icons stand where the rail had them, so opening it moves '
'nothing under the pointer', (tester) async {
await show(tester, width: 1000, toggled: []);
final closed = tester.getCenter(find.byIcon(Icons.map)).dx;
await show(tester, width: 1000, open: true, toggled: []);
expect(tester.getCenter(find.byIcon(Icons.map)).dx, closeTo(closed, 1));
});
testWidgets('kept open in a medium window, it is the rail there', (
tester,
) async {
await show(tester, width: 1000, open: true, toggled: []);
tester.view.physicalSize = const Size(700, 800);
await tester.pumpAndSettle();
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byKey(NavigationFrame.sidebarKey), findsNothing);
});
testWidgets('opening it, the labels come in one after another, top to '
'bottom', (tester) async {
await show(tester, width: 1000, toggled: []);
await show(tester, width: 1000, open: true, toggled: [], settle: false);
await tester.pump(const Duration(milliseconds: 100));
double opacityOf(String label) => tester
.widget<FadeTransition>(
find
.ancestor(
of: find.text(label),
matching: find.byType(FadeTransition),
)
.first,
)
.opacity
.value;
expect(opacityOf('Home'), greaterThan(opacityOf('Profile')));
await tester.pumpAndSettle();
expect(opacityOf('Profile'), 1);
});
testWidgets('without animations, it opens whole at once', (tester) async {
await show(tester, width: 1000, toggled: [], still: true);
await show(
tester,
width: 1000,
open: true,
toggled: [],
still: true,
settle: false,
);
expect(tester.getSize(find.byKey(NavigationFrame.sidebarKey)).width, 256);
expect(
tester
.widget<FadeTransition>(
find
.ancestor(
of: find.text('Profile'),
matching: find.byType(FadeTransition),
)
.first,
)
.opacity
.value,
1,
);
});
testWidgets('its toggle speaks German too', (tester) async {
tester.view.physicalSize = const Size(1000, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
onSidebarChanged: (_) {},
destinations: const [
(label: 'Start', icon: Icon(Icons.home), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
locale: const Locale('de'),
),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Seitenleiste öffnen'), findsOneWidget);
});
}