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:
parent
99b75645ad
commit
3f441cbc98
12 changed files with 766 additions and 31 deletions
|
|
@ -23,6 +23,10 @@ class LookCubit(final LookStore _store, super.initialState)
|
|||
Future<void> chooseLanguage(String? language) =>
|
||||
_apply(state.withLanguage(language));
|
||||
|
||||
/// Opens the rail into the sidebar, or closes it.
|
||||
Future<void> chooseSidebar({required bool open}) =>
|
||||
_apply(state.withSidebar(open: open));
|
||||
|
||||
Future<void> _apply(Look look) async {
|
||||
emit(look);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class FileLookStore(final File _file) implements LookStore {
|
|||
final String tag => tag,
|
||||
_ => null,
|
||||
},
|
||||
sidebarOpen: json['sidebar'] == true,
|
||||
);
|
||||
} on FileSystemException {
|
||||
return const Look();
|
||||
|
|
@ -56,6 +57,7 @@ class FileLookStore(final File _file) implements LookStore {
|
|||
jsonEncode({
|
||||
'appearance': look.appearance.name,
|
||||
'language': ?look.language,
|
||||
if (look.sidebarOpen) 'sidebar': true,
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,23 +16,38 @@ enum Appearance() {
|
|||
typedef AppLanguage = ({String tag, String name});
|
||||
|
||||
/// The device's look of the app (Halkyon Learn's since 2026-10-07, every
|
||||
/// app's since ADR 0014's amendment of 4 Nov 2026): its appearance and the
|
||||
/// language it speaks. It stays on the device, follows nobody to another,
|
||||
/// and applies before anyone is signed in.
|
||||
/// app's since ADR 0014's amendment of 4 Nov 2026): its appearance, the
|
||||
/// language it speaks, and whether the rail is open into the sidebar from
|
||||
/// expanded windows on (`NavigationFrame`, decided 10 Nov 2026). It stays
|
||||
/// on the device, follows nobody to another, and applies before anyone is
|
||||
/// signed in.
|
||||
final class const Look({
|
||||
/// Light, dark, or as the device.
|
||||
final Appearance appearance = Appearance.device,
|
||||
|
||||
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
|
||||
final String? language,
|
||||
|
||||
/// Whether the rail is open into the sidebar.
|
||||
final bool sidebarOpen = false,
|
||||
}) with Equatable {
|
||||
/// This look with [appearance] instead.
|
||||
Look withAppearance(Appearance appearance) =>
|
||||
Look(appearance: appearance, language: language);
|
||||
Look withAppearance(Appearance appearance) => Look(
|
||||
appearance: appearance,
|
||||
language: language,
|
||||
sidebarOpen: sidebarOpen,
|
||||
);
|
||||
|
||||
/// This look speaking [language] instead; null for the device's.
|
||||
Look withLanguage(String? language) =>
|
||||
Look(appearance: appearance, language: language);
|
||||
Look withLanguage(String? language) => Look(
|
||||
appearance: appearance,
|
||||
language: language,
|
||||
sidebarOpen: sidebarOpen,
|
||||
);
|
||||
|
||||
/// This look with the sidebar [open] or closed.
|
||||
Look withSidebar({required bool open}) =>
|
||||
Look(appearance: appearance, language: language, sidebarOpen: open);
|
||||
|
||||
/// The app's theme mode.
|
||||
ThemeMode get themeMode => switch (appearance) {
|
||||
|
|
@ -52,7 +67,7 @@ final class const Look({
|
|||
LookSettings get settings => (themeMode: themeMode, locale: locale);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [appearance, language];
|
||||
List<Object?> get props => [appearance, language, sidebarOpen];
|
||||
}
|
||||
|
||||
/// The locale of the BCP 47 [tag], as `en-GB`: its language and, where it
|
||||
|
|
|
|||
|
|
@ -32,6 +32,31 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('keeps the sidebar open or closed (decided 10 Nov 2026), the '
|
||||
'appearance and language as they were', () async {
|
||||
const look = Look(appearance: Appearance.dark, sidebarOpen: true);
|
||||
await FileLookStore(file).write(look);
|
||||
expect(await FileLookStore(file).read(), look);
|
||||
|
||||
final cubit = await LookCubit.open(FileLookStore(file));
|
||||
addTearDown(cubit.close);
|
||||
await cubit.chooseSidebar(open: false);
|
||||
expect(
|
||||
await FileLookStore(file).read(),
|
||||
const Look(appearance: Appearance.dark),
|
||||
);
|
||||
await cubit.chooseLanguage('de');
|
||||
expect(cubit.state.sidebarOpen, isFalse);
|
||||
});
|
||||
|
||||
test('a file from before the sidebar reads it closed', () async {
|
||||
file.writeAsStringSync('{"appearance": "light"}');
|
||||
expect(
|
||||
await FileLookStore(file).read(),
|
||||
const Look(appearance: Appearance.light),
|
||||
);
|
||||
});
|
||||
|
||||
test("a damaged file or an unknown value reads as the device's", () async {
|
||||
file.writeAsStringSync('{"appearance": "dark", ');
|
||||
expect(await FileLookStore(file).read(), const Look());
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ LeanCode's lints.
|
|||
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
|
||||
`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
|
||||
app's frame around its tabs (the rail with the app's icon, opening from
|
||||
expanded windows on into a sidebar with labels and what the app lists
|
||||
there (`sidebarOpen`, `onSidebarChanged`, `sidebarSections`), the bar flush
|
||||
with the foot with a fade above it, its rounded top corners and the
|
||||
wave a tap sends over it in the colour of the screen in view, the tab
|
||||
chosen in that colour made legible (`readableTone`), without Material's
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"copy": "Kopieren",
|
||||
"paletteColorName": "{name, select, carrot{Karotte} cerise{Kirschrot} cobalt{Kobalt} copper{Kupfer} enzian{Enzian} fern{Farn} forest{Wald} ocean{Ozean} olive{Olive} pacific{Pazifik} pickle{Essiggurke} pine{Kiefer} pink{Rosa} plum{Pflaume} purple{Lila} reef{Riff} sahara{Sahara} slateblue{Schieferblau} soil{Erde} strawberry{Erdbeere} other{{name}}}",
|
||||
"colorPickerLabel": "Farbe: {color}",
|
||||
"explain": "Was ist das?"
|
||||
"explain": "Was ist das?",
|
||||
"openSidebar": "Seitenleiste öffnen",
|
||||
"closeSidebar": "Seitenleiste schließen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,13 @@
|
|||
"explain": "What's this?",
|
||||
"@explain": {
|
||||
"description": "Tooltip of the question mark that opens a text explaining a section or setting (HelpButton)."
|
||||
},
|
||||
"openSidebar": "Open sidebar",
|
||||
"@openSidebar": {
|
||||
"description": "Tooltip of the toggle at the foot of the rail opening it into a sidebar with labels."
|
||||
},
|
||||
"closeSidebar": "Close sidebar",
|
||||
"@closeSidebar": {
|
||||
"description": "Tooltip of the toggle at the foot of the sidebar closing it into the rail."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,18 @@ abstract class HalkyonUiLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'What\'s this?'**
|
||||
String get explain;
|
||||
|
||||
/// Tooltip of the toggle at the foot of the rail opening it into a sidebar with labels.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open sidebar'**
|
||||
String get openSidebar;
|
||||
|
||||
/// Tooltip of the toggle at the foot of the sidebar closing it into the rail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close sidebar'**
|
||||
String get closeSidebar;
|
||||
}
|
||||
|
||||
class _HalkyonUiLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -93,4 +93,10 @@ class HalkyonUiLocalizationsDe extends HalkyonUiLocalizations {
|
|||
|
||||
@override
|
||||
String get explain => 'Was ist das?';
|
||||
|
||||
@override
|
||||
String get openSidebar => 'Seitenleiste öffnen';
|
||||
|
||||
@override
|
||||
String get closeSidebar => 'Seitenleiste schließen';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ class HalkyonUiLocalizationsEn extends HalkyonUiLocalizations {
|
|||
|
||||
@override
|
||||
String get explain => 'What\'s this?';
|
||||
|
||||
@override
|
||||
String get openSidebar => 'Open sidebar';
|
||||
|
||||
@override
|
||||
String get closeSidebar => 'Close sidebar';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_ui/l10n/halkyon_ui_localizations.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/screen_tint.dart';
|
||||
import 'package:halkyon_ui/src/widget/tab_visibility.dart';
|
||||
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A tab of a [NavigationFrame]: its label and icons, built by the app (a
|
||||
|
|
@ -42,6 +44,15 @@ typedef FrameDestination = ({
|
|||
/// [accent] until a screen claims one), legible on the bar, without
|
||||
/// Material's ripple and with its indicator there at once; a tab changed
|
||||
/// otherwise (a link, back) sends no wave, as the wave answers the finger.
|
||||
///
|
||||
/// From expanded windows on, where the app keeps the choice
|
||||
/// ([onSidebarChanged]), a toggle at the rail's foot opens it into a
|
||||
/// sidebar 256 wide ([sidebarOpen]; decided by the developer, 9 and 10 Nov
|
||||
/// 2026, as Proton Drive's): the app named beside its icon, each tab its
|
||||
/// label beside its icon, the icons where the rail had them, then what the
|
||||
/// app lists there ([sidebarSections], Todo's favourites and projects); its
|
||||
/// labels come in one after another, top to bottom, all at once without
|
||||
/// animations. Medium windows keep the rail alone, open or not.
|
||||
class const NavigationFrame({
|
||||
/// The tabs, in order.
|
||||
required final List<FrameDestination> destinations,
|
||||
|
|
@ -78,8 +89,25 @@ class const NavigationFrame({
|
|||
/// windows, at the foot of the screen's panel from there on; none
|
||||
/// while null.
|
||||
final Widget? status,
|
||||
|
||||
/// Whether the rail is open into the sidebar, from expanded windows on.
|
||||
final bool sidebarOpen = false,
|
||||
|
||||
/// Called with whether the sidebar is to be open, once its toggle is
|
||||
/// chosen, for the app to keep (halkyon_app's `Look`); no toggle where
|
||||
/// null.
|
||||
final ValueChanged<bool>? onSidebarChanged,
|
||||
|
||||
/// What the open sidebar lists below the tabs; nothing where null.
|
||||
final Widget? sidebarSections,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// The key of the open sidebar.
|
||||
static const sidebarKey = Key('navigationSidebar');
|
||||
|
||||
/// How wide the open sidebar is (the developer's choice, 10 Nov 2026).
|
||||
static const sidebarWidth = 256.0;
|
||||
|
||||
@override
|
||||
State<NavigationFrame> createState() => _NavigationFrameState();
|
||||
}
|
||||
|
|
@ -131,29 +159,68 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
_phoneMessenger.currentState
|
||||
?..clearSnackBars()
|
||||
..removeCurrentSnackBar();
|
||||
final toggles =
|
||||
widget.onSidebarChanged != null &&
|
||||
context.windowSize.isAtLeastExpanded;
|
||||
final open = toggles && widget.sidebarOpen;
|
||||
final still = MediaQuery.disableAnimationsOf(context);
|
||||
final railIcon = _RailIcon(
|
||||
icon: widget.icon,
|
||||
appName: widget.appName,
|
||||
first: destinations.first.label,
|
||||
onTap: () => _select(0),
|
||||
);
|
||||
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),
|
||||
rail: AnimatedContainer(
|
||||
width: open ? NavigationFrame.sidebarWidth : _railWidth,
|
||||
duration: still ? Duration.zero : const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minWidth: 0,
|
||||
maxWidth: open ? NavigationFrame.sidebarWidth : _railWidth,
|
||||
child: open
|
||||
? _Sidebar(
|
||||
key: NavigationFrame.sidebarKey,
|
||||
icon: railIcon,
|
||||
appName: widget.appName,
|
||||
destinations: destinations,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onSelected: _select,
|
||||
sections: widget.sidebarSections,
|
||||
toggle: _SidebarToggle(
|
||||
open: true,
|
||||
onChanged: widget.onSidebarChanged!,
|
||||
),
|
||||
)
|
||||
: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
onDestinationSelected: _select,
|
||||
leading: railIcon,
|
||||
trailing: toggles
|
||||
? _SidebarToggle(
|
||||
open: false,
|
||||
onChanged: widget.onSidebarChanged!,
|
||||
)
|
||||
: null,
|
||||
trailingAtBottom: true,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: switch (widget.status) {
|
||||
final status? => Column(
|
||||
|
|
@ -285,6 +352,231 @@ class _NavigationFrameState() extends State<NavigationFrame> {
|
|||
}
|
||||
}
|
||||
|
||||
/// How wide the rail is, as Material 3 has it.
|
||||
const _railWidth = 80.0;
|
||||
|
||||
/// The toggle at the foot of the rail and the sidebar, opening or closing
|
||||
/// the sidebar.
|
||||
class const _SidebarToggle({
|
||||
required final bool open,
|
||||
required final ValueChanged<bool> onChanged,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonUiLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(16, 8, 16, 16),
|
||||
child: IconButton(
|
||||
tooltip: open ? l10n.closeSidebar : l10n.openSidebar,
|
||||
onPressed: () => onChanged(!open),
|
||||
icon: Icon(
|
||||
open ? LucideIcons.panelLeftClose : LucideIcons.panelLeftOpen,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rail opened: the app's [icon] and name, each destination its label
|
||||
/// beside its icon in a row as wide as the sidebar, the icons where the
|
||||
/// rail's stand (centred 40 from the start), then [sections], scrolling
|
||||
/// together where they don't fit, and [toggle] at the foot. The labels come
|
||||
/// in one after another as it opens, at once without animations.
|
||||
class const _Sidebar({
|
||||
required final Widget icon,
|
||||
required final String appName,
|
||||
required final List<FrameDestination> destinations,
|
||||
required final int selectedIndex,
|
||||
required final ValueChanged<int> onSelected,
|
||||
required final Widget? sections,
|
||||
required final Widget toggle,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_Sidebar> createState() => _SidebarState();
|
||||
}
|
||||
|
||||
class _SidebarState()
|
||||
extends State<_Sidebar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Each label's start after the one above it, and how long it takes.
|
||||
static const _stagger = Duration(milliseconds: 40);
|
||||
static const _each = Duration(milliseconds: 200);
|
||||
|
||||
late final _labels = AnimationController(
|
||||
vsync: this,
|
||||
duration: _each + _stagger * widget.destinations.length,
|
||||
);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_labels.isDismissed) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_labels.value = 1;
|
||||
} else {
|
||||
_labels.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_labels.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The label in row [index] coming in after those above it.
|
||||
Animation<double> _labelIn(int index) {
|
||||
final total = _labels.duration!.inMilliseconds;
|
||||
final start = (_stagger * index).inMilliseconds / total;
|
||||
return CurvedAnimation(
|
||||
parent: _labels,
|
||||
curve: Interval(
|
||||
start,
|
||||
(start + _each.inMilliseconds / total).clamp(0, 1),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final destinations = widget.destinations;
|
||||
return SafeArea(
|
||||
right: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 20),
|
||||
widget.icon,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Label(
|
||||
animation: _labelIn(0),
|
||||
child: Text(
|
||||
widget.appName,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
children: [
|
||||
for (final (i, destination) in destinations.indexed)
|
||||
_SidebarRow(
|
||||
destination: destination,
|
||||
selected: i == widget.selectedIndex,
|
||||
label: _labelIn(i + 1),
|
||||
onTap: () => widget.onSelected(i),
|
||||
scheme: scheme,
|
||||
),
|
||||
?widget.sections,
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: widget.toggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label coming in: fading in and sliding a little from the start.
|
||||
class const _Label({
|
||||
required final Animation<double> animation,
|
||||
required final Widget child,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(-0.08, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A destination of the open sidebar, its indicator as wide as the row
|
||||
/// when [selected], as Material 3's expanded rail has it.
|
||||
class const _SidebarRow({
|
||||
required final FrameDestination destination,
|
||||
required final bool selected,
|
||||
required final Animation<double> label,
|
||||
required final VoidCallback onTap,
|
||||
required final ColorScheme scheme,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? scheme.onSecondaryContainer : scheme.onSurfaceVariant,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: Material(
|
||||
color: selected ? scheme.secondaryContainer : Colors.transparent,
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
color: selected
|
||||
? scheme.onSecondaryContainer
|
||||
: scheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
child: selected
|
||||
? destination.selectedIcon ?? destination.icon
|
||||
: destination.icon,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Label(
|
||||
animation: label,
|
||||
child: Text(
|
||||
destination.label,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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})
|
||||
|
|
|
|||
245
packages/halkyon_ui/test/widget/navigation_sidebar_test.dart
Normal file
245
packages/halkyon_ui/test/widget/navigation_sidebar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue