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
6 changed files with 766 additions and 31 deletions
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Reference in a new issue