fix(studio): sidebar — single AnimationController drives width
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
+ label opacity in lockstep (no more glitchy expand) The previous "fixed-width icon column" commit already locked the icon's horizontal pixel-X, but the rail still _felt_ glitchy on expand because the rail's geometry and the rail's content were animated by two different mechanisms: - Width: AnimatedContainer over FaiMotion.fast (120 ms) - Content: setState(_hovered = true) → conditional `if (expanded) Expanded(label)` snaps in one frame So on mouse-enter the label widget appeared INSTANTLY while the rail was still 72 px wide. The label tried to render in 0 px of available space and Flutter's layout engine clamped it; over the next 7 frames the rail grew to 220 px and the label visibly "settled in". That's the perceived glitch. Replace the two-source animation with a single SingleTickerProviderStateMixin + AnimationController whose value `t` (0..1, eased via easeInOutCubic) drives both: - rail width = lerp(72, 220, t) - label opacity = t - labelsInteractive = t > 0.5 (so hidden buttons can't eat clicks meant for the icon column) Labels are wrapped in `t > 0 ? IgnorePointer(Opacity(...)) : SizedBox.shrink()`. Once the animation starts, the label joins the tree at opacity ≈ 0 (invisible — no pop) and fades up smoothly as t grows. When fully collapsed (t == 0) the label is removed from layout entirely, so the connection pill's tall "Start hub" affordance doesn't inflate the collapsed rail's height (this also fixes the widget_test vertical-overflow that 0.51.4 introduced). Same pattern applied to the footer (settings icon stays in the 72-px column always; theme/lang/clock fade in beside it) and to each _SidebarItem destination row. End result: width AND content travel together along the same animation curve. No more "snap then catch up" — they're mathematically inseparable. Version 0.51.4 → 0.51.5. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
5830198566
commit
2b65cd771f
2 changed files with 290 additions and 212 deletions
500
lib/main.dart
500
lib/main.dart
|
|
@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// and quick-glance proof that you're seeing the current build.
|
||||
const String kStudioVersion = '0.51.4';
|
||||
const String kStudioVersion = '0.51.5';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -455,205 +455,213 @@ class _Sidebar extends StatefulWidget {
|
|||
State<_Sidebar> createState() => _SidebarState();
|
||||
}
|
||||
|
||||
class _SidebarState extends State<_Sidebar> {
|
||||
// Default-collapsed (icons only); expands on hover. Width
|
||||
// anim mirrors NavigationRail's spec: 72 collapsed, 220
|
||||
// expanded.
|
||||
class _SidebarState extends State<_Sidebar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Default-collapsed (icons only); expands on hover. 72 / 220
|
||||
// mirrors NavigationRail's spec.
|
||||
static const double _collapsedWidth = 72;
|
||||
static const double _expandedWidth = 220;
|
||||
bool _hovered = false;
|
||||
// Single source of truth for the entire rail's expand
|
||||
// animation. width and every label's opacity are derived
|
||||
// from `_ctrl.value` — no second timer, no conditional
|
||||
// widget-tree mutation, so width and content move in
|
||||
// lockstep instead of one snapping while the other glides.
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: FaiMotion.fast,
|
||||
value: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
|
||||
final expanded = _hovered;
|
||||
final width = expanded ? _expandedWidth : _collapsedWidth;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: FaiMotion.fast,
|
||||
width: width,
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Brand row — fixed-width icon column (kCollapsedWidth)
|
||||
// on the left, label/version stacked beside it when
|
||||
// expanded. The 72-px column means the triangle's
|
||||
// pixel-X is identical in both states; only the
|
||||
// rail's RIGHT edge moves on expand, never the
|
||||
// left-anchored content.
|
||||
Row(
|
||||
onEnter: (_) => _ctrl.forward(),
|
||||
onExit: (_) => _ctrl.reverse(),
|
||||
child: AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, _) {
|
||||
// eased between 0 (fully collapsed) and 1 (fully
|
||||
// expanded). Opacity uses the same curve, so a label
|
||||
// that starts fading in is at the same progress
|
||||
// fraction as the rail's geometry — visually they
|
||||
// travel together.
|
||||
final t = Curves.easeInOutCubic.transform(_ctrl.value);
|
||||
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
|
||||
// Hold pointer hits on labels until the rail is at
|
||||
// least half-open; otherwise the operator might tap
|
||||
// through an almost-invisible button on the way to
|
||||
// the icon they actually want.
|
||||
final labelsInteractive = t > 0.5;
|
||||
return Container(
|
||||
width: width,
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _collapsedWidth,
|
||||
child: Center(
|
||||
child: FaiDeltaMark(
|
||||
color: theme.colorScheme.primary,
|
||||
mode: mode,
|
||||
_sidebarRow(
|
||||
leading: FaiDeltaMark(
|
||||
color: theme.colorScheme.primary,
|
||||
mode: mode,
|
||||
),
|
||||
label: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'F∆I Studio',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'v$kStudioVersion',
|
||||
style: FaiTheme.mono(
|
||||
size: 10,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
_sidebarRow(
|
||||
leading: Tooltip(
|
||||
message: widget.connected == true
|
||||
? 'Connected · ${widget.endpointLabel}'
|
||||
: widget.connected == false
|
||||
? 'Disconnected · ${widget.endpointLabel}'
|
||||
: 'Connecting · ${widget.endpointLabel}',
|
||||
child: _CollapsedConnectionDot(
|
||||
connected: widget.connected,
|
||||
),
|
||||
),
|
||||
label: _ConnectionPill(
|
||||
connected: widget.connected,
|
||||
label: widget.endpointLabel,
|
||||
onStartRequested: () async {
|
||||
final r = await SystemActions.faiDaemon(['start']);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
r.ok
|
||||
? 'Daemon start requested. Reconnecting…'
|
||||
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
),
|
||||
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
_sidebarRow(
|
||||
leading: Tooltip(
|
||||
message: widget.activeChannel!,
|
||||
child: _CollapsedChannelChip(
|
||||
channel: widget.activeChannel!,
|
||||
),
|
||||
),
|
||||
label: _ChannelPill(channel: widget.activeChannel!),
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.xxl),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (var i = 0; i < widget.pages.length; i++)
|
||||
_SidebarItem(
|
||||
page: widget.pages[i],
|
||||
selected: i == widget.selectedIndex,
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
iconColumnWidth: _collapsedWidth,
|
||||
onTap: () => widget.onSelect(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (expanded)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'F∆I Studio',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'v$kStudioVersion',
|
||||
style: FaiTheme.mono(
|
||||
size: 10,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
// Connection row — same pattern. Mini-dot sits
|
||||
// centered in the 72-col; the full _ConnectionPill
|
||||
// takes the remaining width when expanded.
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _collapsedWidth,
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: widget.connected == true
|
||||
? 'Connected · ${widget.endpointLabel}'
|
||||
: widget.connected == false
|
||||
? 'Disconnected · ${widget.endpointLabel}'
|
||||
: 'Connecting · ${widget.endpointLabel}',
|
||||
child: _CollapsedConnectionDot(
|
||||
connected: widget.connected,
|
||||
),
|
||||
),
|
||||
),
|
||||
_Footer(
|
||||
t: t,
|
||||
labelsInteractive: labelsInteractive,
|
||||
iconColumnWidth: _collapsedWidth,
|
||||
onOpenSettings: () => _openSettings(context),
|
||||
),
|
||||
if (expanded)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: _ConnectionPill(
|
||||
connected: widget.connected,
|
||||
label: widget.endpointLabel,
|
||||
onStartRequested: () async {
|
||||
final r =
|
||||
await SystemActions.faiDaemon(['start']);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
r.ok
|
||||
? 'Daemon start requested. Reconnecting…'
|
||||
: 'Could not start daemon: ${r.stderr.trim().isEmpty ? r.stdout.trim() : r.stderr.trim()}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.activeChannel != null && widget.activeChannel!.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _collapsedWidth,
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: widget.activeChannel!,
|
||||
child: _CollapsedChannelChip(
|
||||
channel: widget.activeChannel!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expanded)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: _ChannelPill(
|
||||
channel: widget.activeChannel!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.xxl),
|
||||
// Destinations — same fixed-width icon column,
|
||||
// optional label slot on the right. ListView's
|
||||
// horizontal padding stays zero so the SizedBox
|
||||
// anchors at the rail's literal left edge.
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (var i = 0; i < widget.pages.length; i++)
|
||||
_SidebarItem(
|
||||
page: widget.pages[i],
|
||||
selected: i == widget.selectedIndex,
|
||||
expanded: expanded,
|
||||
iconColumnWidth: _collapsedWidth,
|
||||
onTap: () => widget.onSelect(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Footer: theme toggle + clock + settings when
|
||||
// expanded; just the settings icon when collapsed.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||
child: expanded
|
||||
? Row(
|
||||
children: [
|
||||
_ThemeToggle(),
|
||||
_LanguageToggle(),
|
||||
const Expanded(
|
||||
child: Center(child: _SidebarClock()),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
tooltip: 'Settings (⌘;)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openSettings(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
tooltip: 'Settings (⌘;)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openSettings(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared row geometry for the brand / connection / channel
|
||||
/// headers. Leading widget sits in a fixed-width icon column
|
||||
/// anchored to the rail's left edge. The label slot is in
|
||||
/// the tree as soon as the animation kicks off (t > 0) and
|
||||
/// fades in via Opacity tied to the same controller. Skipping
|
||||
/// layout at t == 0 keeps the row's collapsed height = the
|
||||
/// leading widget's natural height (rather than including
|
||||
/// the label's tall "Start hub" affordance even when
|
||||
/// invisible). The opacity at t ≈ 0.01 is low enough that
|
||||
/// the layout's first-frame appearance isn't perceived as a
|
||||
/// pop — the label visually grows in from invisible.
|
||||
Widget _sidebarRow({
|
||||
required Widget leading,
|
||||
required Widget label,
|
||||
required double t,
|
||||
required bool labelsInteractive,
|
||||
}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _collapsedWidth,
|
||||
child: Center(child: leading),
|
||||
),
|
||||
Expanded(
|
||||
child: t > 0
|
||||
? IgnorePointer(
|
||||
ignoring: !labelsInteractive,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSettings(BuildContext context) async {
|
||||
final saved = await FaiSettingsDialog.show(context);
|
||||
if (saved && context.mounted) {
|
||||
|
|
@ -930,14 +938,20 @@ class _ConnectionPill extends StatelessWidget {
|
|||
class _SidebarItem extends StatefulWidget {
|
||||
final _NavPage page;
|
||||
final bool selected;
|
||||
final bool expanded;
|
||||
/// 0 = fully collapsed, 1 = fully expanded. Drives label
|
||||
/// opacity in lockstep with the parent rail's width
|
||||
/// animation — there is no second timer for the label, so
|
||||
/// label and rail can't fall out of sync.
|
||||
final double t;
|
||||
final bool labelsInteractive;
|
||||
final double iconColumnWidth;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SidebarItem({
|
||||
required this.page,
|
||||
required this.selected,
|
||||
required this.expanded,
|
||||
required this.t,
|
||||
required this.labelsInteractive,
|
||||
required this.iconColumnWidth,
|
||||
required this.onTap,
|
||||
});
|
||||
|
|
@ -969,38 +983,41 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
size: 18,
|
||||
color: color,
|
||||
);
|
||||
// Fixed-width icon column on the left + optional label slot
|
||||
// on the right. The icon's pixel-X is anchored to the rail's
|
||||
// literal left edge (no ListView padding, no AC h-padding)
|
||||
// and the icon column width matches the rail's collapsed
|
||||
// width, so the icon sits at the same x-pixel — center of
|
||||
// the 72-px slot — regardless of whether the rail is
|
||||
// currently 72 or 220 wide. The label appears as an
|
||||
// Expanded sibling to the icon column only when expanded.
|
||||
// Label widget is ALWAYS in the tree — opacity = t makes
|
||||
// it fade in/out in lockstep with the rail's width
|
||||
// animation. Expanded's flex space goes from 0 (at t=0,
|
||||
// rail width = 72) to (rail width − 72) at t=1, so the
|
||||
// label has natural space to fill without any conditional
|
||||
// tree mutation.
|
||||
final content = Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: widget.iconColumnWidth,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
if (widget.expanded)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: widget.t > 0
|
||||
? IgnorePointer(
|
||||
ignoring: !widget.labelsInteractive,
|
||||
child: Opacity(
|
||||
opacity: widget.t,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final inner = Padding(
|
||||
// Vertical breathing room between items. No horizontal
|
||||
// padding — the icon column itself owns the left anchor.
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
|
|
@ -1011,9 +1028,9 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: FaiMotion.fast,
|
||||
// Only vertical padding — horizontal is owned by the
|
||||
// icon column. Background highlight spans the full
|
||||
// rail width, which is the typical nav-rail UX.
|
||||
// No horizontal padding — the icon column anchors
|
||||
// the left. The bg highlight spans the rail's
|
||||
// current width (typical nav-rail behaviour).
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||||
decoration: BoxDecoration(color: bg),
|
||||
child: content,
|
||||
|
|
@ -1022,9 +1039,9 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
),
|
||||
);
|
||||
|
||||
// Tooltip only when collapsed — when the rail is expanded
|
||||
// the label is visible inline + tooltips become noisy.
|
||||
if (widget.expanded) return inner;
|
||||
// Tooltip only while the label is hidden — once the label
|
||||
// is visible inline the tooltip becomes redundant noise.
|
||||
if (widget.t > 0.5) return inner;
|
||||
return Tooltip(
|
||||
message: label,
|
||||
preferBelow: false,
|
||||
|
|
@ -1033,6 +1050,67 @@ class _SidebarItemState extends State<_SidebarItem> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bottom-of-rail control strip. Settings icon is always the
|
||||
/// last item in the icon column so its pixel-X never shifts;
|
||||
/// theme + language toggles and clock fade in beside it as
|
||||
/// the rail expands, sharing the same `t` as the rest of the
|
||||
/// sidebar so motion stays in lockstep.
|
||||
class _Footer extends StatelessWidget {
|
||||
final double t;
|
||||
final bool labelsInteractive;
|
||||
final double iconColumnWidth;
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
const _Footer({
|
||||
required this.t,
|
||||
required this.labelsInteractive,
|
||||
required this.iconColumnWidth,
|
||||
required this.onOpenSettings,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: iconColumnWidth,
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
tooltip: 'Settings (⌘;)',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onOpenSettings,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: t > 0
|
||||
? IgnorePointer(
|
||||
ignoring: !labelsInteractive,
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
_ThemeToggle(),
|
||||
_LanguageToggle(),
|
||||
const Expanded(
|
||||
child: Center(child: _SidebarClock()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavPage {
|
||||
/// Stable id used for the Cmd+K palette and the sidebar
|
||||
/// shortcut keys; never shown to the operator. The visible
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue