kasy-cli 1.39.1 → 1.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +27 -27
- package/templates/firebase/lib/components/components.dart +2 -0
- package/templates/firebase/lib/components/kasy_accordion.dart +4 -1
- package/templates/firebase/lib/components/kasy_alert.dart +5 -2
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
- package/templates/firebase/lib/components/kasy_dialog.dart +3 -1
- package/templates/firebase/lib/components/kasy_menu.dart +926 -0
- package/templates/firebase/lib/components/kasy_popover.dart +267 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +20 -10
- package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
- package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +320 -0
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
- package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
- package/templates/firebase/lib/router.dart +2 -0
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +0 -81
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
|
|
3
|
+
import 'package:kasy_kit/components/kasy_popover.dart';
|
|
4
|
+
import 'package:kasy_kit/core/theme/theme.dart';
|
|
5
|
+
import 'package:kasy_kit/core/widgets/kasy_hover.dart';
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// KasyMenu — design-system action menu (HeroUI "Dropdown" / Menu).
|
|
9
|
+
//
|
|
10
|
+
// A list of contextual actions presented in a floating panel. Mirrors the
|
|
11
|
+
// HeroUI Menu anatomy and reuses the visual language of [KasyDropDown] (panel
|
|
12
|
+
// radius, item radius, 14px medium labels, selected check, "Danger zone"
|
|
13
|
+
// grouping) so the kit stays consistent.
|
|
14
|
+
//
|
|
15
|
+
// Anatomy:
|
|
16
|
+
// • KasyMenuItem — one actionable row: prefix icon (or selection check),
|
|
17
|
+
// title, optional description, optional suffix (keyboard
|
|
18
|
+
// shortcut / submenu chevron / custom), tone (initial |
|
|
19
|
+
// danger), selected / disabled states, optional submenu.
|
|
20
|
+
// • KasyMenuSection — a group of items with an optional muted label; sections
|
|
21
|
+
// are separated by a divider (e.g. "Actions"/"Danger zone").
|
|
22
|
+
// • KasyMenu — the chrome-less panel that stacks sections. The floating
|
|
23
|
+
// surface (border, shadow, scroll) is supplied by the
|
|
24
|
+
// presenter, so the menu never double-draws a card.
|
|
25
|
+
//
|
|
26
|
+
// Present it with [showKasyMenu]. By default the presentation adapts to the
|
|
27
|
+
// platform (popover on desktop, bottom sheet on mobile), but it can be forced
|
|
28
|
+
// either way with [KasyMenuPresentation]. Items can close the menu on tap or
|
|
29
|
+
// stay open ([closeOnSelect]).
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/// Visual type of a [KasyMenuItem] (HeroUI: Initial | Danger).
|
|
33
|
+
enum KasyMenuItemTone {
|
|
34
|
+
/// Default neutral action.
|
|
35
|
+
initial,
|
|
36
|
+
|
|
37
|
+
/// Destructive / irreversible action (delete, remove) — rendered in the error
|
|
38
|
+
/// color. Use sparingly, in its own ("Danger zone") [KasyMenuSection].
|
|
39
|
+
danger,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Row density. [compact] matches the HeroUI desktop spec (36px rows, 14px
|
|
43
|
+
/// labels, 16px icons); [comfortable] enlarges rows for touch on mobile.
|
|
44
|
+
enum KasyMenuDensity { compact, comfortable }
|
|
45
|
+
|
|
46
|
+
/// Selection indicator a [KasyMenuSection] draws for its selected items.
|
|
47
|
+
enum KasyMenuSelectionMode {
|
|
48
|
+
/// Plain action items — no selection indicator.
|
|
49
|
+
none,
|
|
50
|
+
|
|
51
|
+
/// One choice at a time — the selected item shows a filled radio dot.
|
|
52
|
+
single,
|
|
53
|
+
|
|
54
|
+
/// Several choices at once — each selected item shows a check.
|
|
55
|
+
multiple,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// How [showKasyMenu] presents the menu.
|
|
59
|
+
enum KasyMenuPresentation {
|
|
60
|
+
/// Popover anchored to the trigger on desktop, bottom sheet on mobile.
|
|
61
|
+
auto,
|
|
62
|
+
|
|
63
|
+
/// Always a popover anchored to the trigger (both platforms).
|
|
64
|
+
popover,
|
|
65
|
+
|
|
66
|
+
/// Always a bottom sheet sliding up from the bottom (both platforms).
|
|
67
|
+
bottomSheet,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// One actionable row in a [KasyMenu].
|
|
71
|
+
class KasyMenuItem {
|
|
72
|
+
/// Main action label (required).
|
|
73
|
+
final String title;
|
|
74
|
+
|
|
75
|
+
/// Optional supporting line beneath the title (muted).
|
|
76
|
+
final String? description;
|
|
77
|
+
|
|
78
|
+
/// Optional leading icon (HeroUI "Prefix"). Omitted on selectable items, where
|
|
79
|
+
/// the prefix slot shows a check for the selected row instead.
|
|
80
|
+
final IconData? icon;
|
|
81
|
+
|
|
82
|
+
/// Optional keyboard shortcut shown as a trailing chip (HeroUI "Kbd"), e.g.
|
|
83
|
+
/// '⌘ B'. Ignored when [trailing] or [submenu] is set.
|
|
84
|
+
final String? shortcut;
|
|
85
|
+
|
|
86
|
+
/// Optional custom trailing widget (HeroUI "Suffix"). Overrides [shortcut].
|
|
87
|
+
final Widget? trailing;
|
|
88
|
+
|
|
89
|
+
/// Initial (neutral) or danger (destructive) styling.
|
|
90
|
+
final KasyMenuItemTone tone;
|
|
91
|
+
|
|
92
|
+
/// Marks the row as the current selection (primary label + leading check).
|
|
93
|
+
final bool selected;
|
|
94
|
+
|
|
95
|
+
/// When false the row is dimmed and not tappable.
|
|
96
|
+
final bool enabled;
|
|
97
|
+
|
|
98
|
+
/// When set, the row opens a nested menu instead of acting; a trailing chevron
|
|
99
|
+
/// is shown. Where there's room it cascades as a side flyout (desktop); on a
|
|
100
|
+
/// narrow screen it expands inline beneath the row (accordion).
|
|
101
|
+
final List<KasyMenuSection>? submenu;
|
|
102
|
+
|
|
103
|
+
/// Whether tapping this row closes the menu. Null inherits the menu-level
|
|
104
|
+
/// [KasyMenu.closeOnSelect]. Set false for toggles that keep the menu open.
|
|
105
|
+
final bool? closeOnSelect;
|
|
106
|
+
|
|
107
|
+
/// Invoked when the row is tapped (after the menu closes, if it closes).
|
|
108
|
+
final VoidCallback? onTap;
|
|
109
|
+
|
|
110
|
+
const KasyMenuItem({
|
|
111
|
+
required this.title,
|
|
112
|
+
this.description,
|
|
113
|
+
this.icon,
|
|
114
|
+
this.shortcut,
|
|
115
|
+
this.trailing,
|
|
116
|
+
this.tone = KasyMenuItemTone.initial,
|
|
117
|
+
this.selected = false,
|
|
118
|
+
this.enabled = true,
|
|
119
|
+
this.submenu,
|
|
120
|
+
this.closeOnSelect,
|
|
121
|
+
this.onTap,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// A labelled group of [KasyMenuItem]s. Sections are divided from each other so
|
|
126
|
+
/// destructive actions can sit in their own ("Danger zone") group.
|
|
127
|
+
class KasyMenuSection {
|
|
128
|
+
/// Optional muted group header (HeroUI "Groups", e.g. "Actions").
|
|
129
|
+
final String? label;
|
|
130
|
+
|
|
131
|
+
/// Selection indicator for this group's selected items (check vs radio dot).
|
|
132
|
+
final KasyMenuSelectionMode selectionMode;
|
|
133
|
+
final List<KasyMenuItem> items;
|
|
134
|
+
|
|
135
|
+
const KasyMenuSection({
|
|
136
|
+
this.label,
|
|
137
|
+
this.selectionMode = KasyMenuSelectionMode.none,
|
|
138
|
+
required this.items,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The chrome-less menu panel: stacks [sections] with group labels and dividers.
|
|
143
|
+
/// Wrap it in a floating surface to display (the presenters do this for you).
|
|
144
|
+
class KasyMenu extends StatelessWidget {
|
|
145
|
+
final List<KasyMenuSection> sections;
|
|
146
|
+
final KasyMenuDensity density;
|
|
147
|
+
|
|
148
|
+
/// Whether tapping a row closes the menu by default (rows can override).
|
|
149
|
+
final bool closeOnSelect;
|
|
150
|
+
|
|
151
|
+
/// How to dismiss the menu when a row closes it. [KasyMenuAnchor] passes its
|
|
152
|
+
/// own close (it lives in an overlay, not a route); when null the menu assumes
|
|
153
|
+
/// it's a route and pops the navigator (the [showKasyMenu] popover / sheet).
|
|
154
|
+
final VoidCallback? onClose;
|
|
155
|
+
|
|
156
|
+
/// Shared [TapRegion] group of the presenter. A submenu flyout joins this
|
|
157
|
+
/// group so tapping inside it counts as "inside" the menu and never dismisses
|
|
158
|
+
/// the parent. Null for a route-based menu (the route's barrier handles taps).
|
|
159
|
+
final Object? tapGroupId;
|
|
160
|
+
|
|
161
|
+
const KasyMenu({
|
|
162
|
+
super.key,
|
|
163
|
+
required this.sections,
|
|
164
|
+
this.density = KasyMenuDensity.compact,
|
|
165
|
+
this.closeOnSelect = true,
|
|
166
|
+
this.onClose,
|
|
167
|
+
this.tapGroupId,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/// Convenience for a single ungrouped menu.
|
|
171
|
+
factory KasyMenu.items(
|
|
172
|
+
List<KasyMenuItem> items, {
|
|
173
|
+
Key? key,
|
|
174
|
+
KasyMenuDensity density = KasyMenuDensity.compact,
|
|
175
|
+
bool closeOnSelect = true,
|
|
176
|
+
VoidCallback? onClose,
|
|
177
|
+
Object? tapGroupId,
|
|
178
|
+
}) =>
|
|
179
|
+
KasyMenu(
|
|
180
|
+
key: key,
|
|
181
|
+
sections: [KasyMenuSection(items: items)],
|
|
182
|
+
density: density,
|
|
183
|
+
closeOnSelect: closeOnSelect,
|
|
184
|
+
onClose: onClose,
|
|
185
|
+
tapGroupId: tapGroupId,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
@override
|
|
189
|
+
Widget build(BuildContext context) {
|
|
190
|
+
final bool compact = density == KasyMenuDensity.compact;
|
|
191
|
+
final List<Widget> children = [];
|
|
192
|
+
|
|
193
|
+
for (int s = 0; s < sections.length; s++) {
|
|
194
|
+
final KasyMenuSection section = sections[s];
|
|
195
|
+
// Reserve the prefix slot for every row in the group when any row carries
|
|
196
|
+
// an icon or a selection indicator, so labels stay aligned (HeroUI).
|
|
197
|
+
final bool reserveLeading =
|
|
198
|
+
section.selectionMode != KasyMenuSelectionMode.none ||
|
|
199
|
+
section.items.any((i) => i.icon != null || i.selected);
|
|
200
|
+
if (s > 0) children.add(_MenuDivider());
|
|
201
|
+
final String? label = section.label;
|
|
202
|
+
if (label != null && label.trim().isNotEmpty) {
|
|
203
|
+
children.add(_MenuGroupLabel(label: label, compact: compact));
|
|
204
|
+
}
|
|
205
|
+
for (final KasyMenuItem item in section.items) {
|
|
206
|
+
children.add(
|
|
207
|
+
_MenuItemRow(
|
|
208
|
+
item: item,
|
|
209
|
+
compact: compact,
|
|
210
|
+
reserveLeading: reserveLeading,
|
|
211
|
+
selectionMode: section.selectionMode,
|
|
212
|
+
menuCloseOnSelect: closeOnSelect,
|
|
213
|
+
onClose: onClose,
|
|
214
|
+
tapGroupId: tapGroupId,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return Padding(
|
|
221
|
+
// HeroUI: 4px panel inset so a hovered row's rounded fill never touches
|
|
222
|
+
// the panel edge.
|
|
223
|
+
padding: const EdgeInsets.all(KasySpacing.xs),
|
|
224
|
+
child: Column(
|
|
225
|
+
mainAxisSize: MainAxisSize.min,
|
|
226
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
227
|
+
children: children,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// Presents [sections] as a [KasyMenu].
|
|
234
|
+
///
|
|
235
|
+
/// [presentation] picks the form. The default — [KasyMenuPresentation.popover] —
|
|
236
|
+
/// shows a floating menu anchored to [anchorContext] on every platform (mobile
|
|
237
|
+
/// and desktop). Opt into [bottomSheet] to slide it up from the bottom instead,
|
|
238
|
+
/// or [auto] to adapt per platform (popover on desktop, bottom sheet on mobile).
|
|
239
|
+
/// [closeOnSelect] is the default for whether tapping a row dismisses the menu.
|
|
240
|
+
Future<T?> showKasyMenu<T>({
|
|
241
|
+
required BuildContext context,
|
|
242
|
+
required List<KasyMenuSection> sections,
|
|
243
|
+
BuildContext? anchorContext,
|
|
244
|
+
KasyPopoverAlign desktopAlign = KasyPopoverAlign.start,
|
|
245
|
+
KasyPopoverPlacement placement = KasyPopoverPlacement.bottom,
|
|
246
|
+
double desktopWidth = 240,
|
|
247
|
+
KasyMenuPresentation presentation = KasyMenuPresentation.popover,
|
|
248
|
+
bool closeOnSelect = true,
|
|
249
|
+
}) {
|
|
250
|
+
final bool asPopover = switch (presentation) {
|
|
251
|
+
KasyMenuPresentation.auto => kasySheetUsesDialog(context),
|
|
252
|
+
KasyMenuPresentation.popover => true,
|
|
253
|
+
KasyMenuPresentation.bottomSheet => false,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (asPopover) {
|
|
257
|
+
return showKasyPopover<T>(
|
|
258
|
+
anchorContext: anchorContext ?? context,
|
|
259
|
+
align: desktopAlign,
|
|
260
|
+
placement: placement,
|
|
261
|
+
width: desktopWidth,
|
|
262
|
+
builder: (_) => KasyMenu(sections: sections, closeOnSelect: closeOnSelect),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
final bool dark = Theme.of(context).brightness == Brightness.dark;
|
|
267
|
+
return showModalBottomSheet<T>(
|
|
268
|
+
context: context,
|
|
269
|
+
useRootNavigator: true,
|
|
270
|
+
backgroundColor: Colors.transparent,
|
|
271
|
+
barrierColor: Colors.black.withValues(alpha: dark ? 0.6 : 0.45),
|
|
272
|
+
// Same compact, elegant menu — only the container changes (sheet vs popover).
|
|
273
|
+
// The bottom sheet must NOT enlarge the rows.
|
|
274
|
+
builder: (_) => KasySheetSurface(
|
|
275
|
+
child: KasyMenu(sections: sections, closeOnSelect: closeOnSelect),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Declarative trigger that opens a [KasyMenu] glued to itself.
|
|
281
|
+
///
|
|
282
|
+
/// The panel is centered on the trigger and hangs just below it, flipping to
|
|
283
|
+
/// hang above when there isn't room. It rides a [LayerLink] +
|
|
284
|
+
/// [CompositedTransformFollower] hosted by an [OverlayPortal] (the [KasyDropDown]
|
|
285
|
+
/// mechanism): the panel is pinned to the trigger at the compositor level, so it
|
|
286
|
+
/// stays glued even under an ancestor that scales or offsets the subtree (the
|
|
287
|
+
/// device-preview frame, an animated page), and the framework owns the overlay's
|
|
288
|
+
/// lifecycle so there's no manual entry juggling to corrupt the element tree.
|
|
289
|
+
///
|
|
290
|
+
/// [builder] renders the trigger and receives an `open` callback to wire to its
|
|
291
|
+
/// tap. Set [presentation] to [KasyMenuPresentation.bottomSheet] to slide the
|
|
292
|
+
/// menu up from the bottom instead of anchoring it.
|
|
293
|
+
class KasyMenuAnchor extends StatefulWidget {
|
|
294
|
+
final List<KasyMenuSection> sections;
|
|
295
|
+
|
|
296
|
+
/// Builds the trigger; call `open` to show the menu.
|
|
297
|
+
final Widget Function(BuildContext context, VoidCallback open) builder;
|
|
298
|
+
final double width;
|
|
299
|
+
final bool closeOnSelect;
|
|
300
|
+
final KasyMenuPresentation presentation;
|
|
301
|
+
|
|
302
|
+
const KasyMenuAnchor({
|
|
303
|
+
super.key,
|
|
304
|
+
required this.sections,
|
|
305
|
+
required this.builder,
|
|
306
|
+
this.width = 240,
|
|
307
|
+
this.closeOnSelect = true,
|
|
308
|
+
this.presentation = KasyMenuPresentation.popover,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
State<KasyMenuAnchor> createState() => _KasyMenuAnchorState();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
class _KasyMenuAnchorState extends State<KasyMenuAnchor>
|
|
316
|
+
with SingleTickerProviderStateMixin {
|
|
317
|
+
// LayerLink glues the panel to the trigger at the compositor level, so it
|
|
318
|
+
// can't drift even under the device-preview frame's scale/offset. OverlayPortal
|
|
319
|
+
// hosts the panel declaratively — the framework owns its element lifecycle, so
|
|
320
|
+
// there's no manual insert/remove that can corrupt the element tree.
|
|
321
|
+
final LayerLink _link = LayerLink();
|
|
322
|
+
final OverlayPortalController _portal = OverlayPortalController();
|
|
323
|
+
// Groups trigger + panel so a tap on either counts as "inside" for TapRegion.
|
|
324
|
+
final Object _tapGroupId = Object();
|
|
325
|
+
|
|
326
|
+
late final AnimationController _anim;
|
|
327
|
+
late final Animation<double> _fade;
|
|
328
|
+
late final Animation<double> _scale;
|
|
329
|
+
|
|
330
|
+
// Whether the panel hangs above the trigger (no room below), set at open time.
|
|
331
|
+
bool _openUp = false;
|
|
332
|
+
|
|
333
|
+
@override
|
|
334
|
+
void initState() {
|
|
335
|
+
super.initState();
|
|
336
|
+
_anim = AnimationController(
|
|
337
|
+
vsync: this,
|
|
338
|
+
duration: const Duration(milliseconds: 160),
|
|
339
|
+
);
|
|
340
|
+
_fade = CurvedAnimation(parent: _anim, curve: Curves.easeOut);
|
|
341
|
+
_scale = Tween<double>(begin: 0.96, end: 1.0).animate(
|
|
342
|
+
CurvedAnimation(parent: _anim, curve: Curves.easeOutCubic),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
@override
|
|
347
|
+
void dispose() {
|
|
348
|
+
_anim.dispose();
|
|
349
|
+
super.dispose();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
void _open() {
|
|
353
|
+
if (widget.presentation == KasyMenuPresentation.bottomSheet) {
|
|
354
|
+
_openSheet();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
setState(() => _openUp = _resolveOpenUp());
|
|
358
|
+
_portal.show();
|
|
359
|
+
_anim.forward(from: 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Decide the open direction only. Position is handled by the LayerLink, so a
|
|
363
|
+
// slightly-off estimate just flips the menu — it always stays glued. Mirrors
|
|
364
|
+
// [KasyDropDown]: measure against the viewport minus the safe-area insets
|
|
365
|
+
// (notch / home indicator) so the panel never opens under them.
|
|
366
|
+
bool _resolveOpenUp() {
|
|
367
|
+
final RenderBox? box = context.findRenderObject() as RenderBox?;
|
|
368
|
+
if (box == null) return false;
|
|
369
|
+
final Offset topLeft = box.localToGlobal(Offset.zero);
|
|
370
|
+
final Size viewport = MediaQuery.sizeOf(context);
|
|
371
|
+
final EdgeInsets safe = MediaQuery.viewPaddingOf(context);
|
|
372
|
+
final double triggerBottom = topLeft.dy + box.size.height;
|
|
373
|
+
final double spaceBelow = viewport.height - safe.bottom - triggerBottom;
|
|
374
|
+
final double spaceAbove = topLeft.dy - safe.top;
|
|
375
|
+
return spaceBelow < _estimatedHeight() + 8 && spaceAbove > spaceBelow;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
Future<void> _openSheet() {
|
|
379
|
+
final bool dark = Theme.of(context).brightness == Brightness.dark;
|
|
380
|
+
return showModalBottomSheet<void>(
|
|
381
|
+
context: context,
|
|
382
|
+
useRootNavigator: true,
|
|
383
|
+
backgroundColor: Colors.transparent,
|
|
384
|
+
barrierColor: Colors.black.withValues(alpha: dark ? 0.6 : 0.45),
|
|
385
|
+
builder: (_) => KasySheetSurface(
|
|
386
|
+
child: KasyMenu(
|
|
387
|
+
sections: widget.sections,
|
|
388
|
+
closeOnSelect: widget.closeOnSelect,
|
|
389
|
+
),
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
void _close() {
|
|
395
|
+
_anim.reverse().then((_) {
|
|
396
|
+
if (mounted) _portal.hide();
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Rough panel height (rows + labels + dividers + padding), enough to decide
|
|
401
|
+
// the flip — the real layout still clamps to the viewport.
|
|
402
|
+
double _estimatedHeight() {
|
|
403
|
+
double h = KasySpacing.xs * 2;
|
|
404
|
+
for (int s = 0; s < widget.sections.length; s++) {
|
|
405
|
+
final KasyMenuSection section = widget.sections[s];
|
|
406
|
+
if (s > 0) h += 17;
|
|
407
|
+
final String? label = section.label;
|
|
408
|
+
if (label != null && label.trim().isNotEmpty) h += 28;
|
|
409
|
+
for (final KasyMenuItem item in section.items) {
|
|
410
|
+
final bool hasDesc =
|
|
411
|
+
item.description != null && item.description!.isNotEmpty;
|
|
412
|
+
h += hasDesc ? 52 : 40;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return h;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@override
|
|
419
|
+
Widget build(BuildContext context) {
|
|
420
|
+
return TapRegion(
|
|
421
|
+
groupId: _tapGroupId,
|
|
422
|
+
child: OverlayPortal(
|
|
423
|
+
controller: _portal,
|
|
424
|
+
overlayChildBuilder: _buildOverlay,
|
|
425
|
+
child: CompositedTransformTarget(
|
|
426
|
+
link: _link,
|
|
427
|
+
child: widget.builder(context, _open),
|
|
428
|
+
),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
Widget _buildOverlay(BuildContext context) {
|
|
434
|
+
// Center the panel on the trigger (bottom-center → top-center), or flip to
|
|
435
|
+
// hang above. The LayerLink does the positioning; we only pick the anchors.
|
|
436
|
+
final Alignment target = _openUp
|
|
437
|
+
? Alignment.topCenter
|
|
438
|
+
: Alignment.bottomCenter;
|
|
439
|
+
final Alignment follower = _openUp
|
|
440
|
+
? Alignment.bottomCenter
|
|
441
|
+
: Alignment.topCenter;
|
|
442
|
+
final Offset offset = _openUp ? const Offset(0, -6) : const Offset(0, 6);
|
|
443
|
+
final Size viewport = MediaQuery.sizeOf(context);
|
|
444
|
+
|
|
445
|
+
return TapRegion(
|
|
446
|
+
groupId: _tapGroupId,
|
|
447
|
+
onTapOutside: (_) => _close(),
|
|
448
|
+
child: Stack(
|
|
449
|
+
children: [
|
|
450
|
+
CompositedTransformFollower(
|
|
451
|
+
link: _link,
|
|
452
|
+
targetAnchor: target,
|
|
453
|
+
followerAnchor: follower,
|
|
454
|
+
offset: offset,
|
|
455
|
+
child: FadeTransition(
|
|
456
|
+
opacity: _fade,
|
|
457
|
+
child: ScaleTransition(
|
|
458
|
+
scale: _scale,
|
|
459
|
+
alignment: follower,
|
|
460
|
+
child: KasyPopoverSurface(
|
|
461
|
+
width: widget.width,
|
|
462
|
+
maxHeight: viewport.height * 0.8,
|
|
463
|
+
child: KasyMenu(
|
|
464
|
+
sections: widget.sections,
|
|
465
|
+
closeOnSelect: widget.closeOnSelect,
|
|
466
|
+
onClose: _close,
|
|
467
|
+
tapGroupId: _tapGroupId,
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
],
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Internals ────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
/// Side a submenu flyout opens toward, or [none] when neither side has room (the
|
|
482
|
+
/// row then presents its submenu as a sheet instead of a clipped flyout).
|
|
483
|
+
enum _FlyoutSide { left, right, none }
|
|
484
|
+
|
|
485
|
+
class _MenuGroupLabel extends StatelessWidget {
|
|
486
|
+
final String label;
|
|
487
|
+
final bool compact;
|
|
488
|
+
|
|
489
|
+
const _MenuGroupLabel({required this.label, required this.compact});
|
|
490
|
+
|
|
491
|
+
@override
|
|
492
|
+
Widget build(BuildContext context) {
|
|
493
|
+
return Padding(
|
|
494
|
+
// HeroUI: pt 10 / pb 4 / px 12 for group headers.
|
|
495
|
+
padding: EdgeInsets.fromLTRB(
|
|
496
|
+
KasySpacing.smd,
|
|
497
|
+
compact ? 10 : KasySpacing.smd,
|
|
498
|
+
KasySpacing.smd,
|
|
499
|
+
KasySpacing.xs,
|
|
500
|
+
),
|
|
501
|
+
child: Align(
|
|
502
|
+
alignment: Alignment.centerLeft,
|
|
503
|
+
child: Text(
|
|
504
|
+
label,
|
|
505
|
+
style: context.textTheme.bodySmall?.copyWith(
|
|
506
|
+
color: context.colors.muted,
|
|
507
|
+
fontWeight: FontWeight.w500,
|
|
508
|
+
),
|
|
509
|
+
),
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
class _MenuDivider extends StatelessWidget {
|
|
516
|
+
@override
|
|
517
|
+
Widget build(BuildContext context) {
|
|
518
|
+
return Padding(
|
|
519
|
+
padding: const EdgeInsets.symmetric(
|
|
520
|
+
horizontal: KasySpacing.smd,
|
|
521
|
+
vertical: KasySpacing.xs,
|
|
522
|
+
),
|
|
523
|
+
child: Container(
|
|
524
|
+
height: 1,
|
|
525
|
+
color: context.colors.onSurface.withValues(alpha: 0.08),
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/// Trailing keyboard shortcut (HeroUI "Kbd", light variant). No filled pill —
|
|
532
|
+
/// just the muted command glyphs, as the Figma menu uses.
|
|
533
|
+
class _MenuShortcut extends StatelessWidget {
|
|
534
|
+
final String label;
|
|
535
|
+
final bool compact;
|
|
536
|
+
|
|
537
|
+
const _MenuShortcut({required this.label, required this.compact});
|
|
538
|
+
|
|
539
|
+
@override
|
|
540
|
+
Widget build(BuildContext context) {
|
|
541
|
+
return Text(
|
|
542
|
+
label,
|
|
543
|
+
style:
|
|
544
|
+
(compact ? context.textTheme.bodyMedium : context.textTheme.bodyLarge)
|
|
545
|
+
?.copyWith(
|
|
546
|
+
color: context.colors.muted,
|
|
547
|
+
fontWeight: FontWeight.w500,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
class _MenuItemRow extends StatefulWidget {
|
|
554
|
+
final KasyMenuItem item;
|
|
555
|
+
final bool compact;
|
|
556
|
+
final bool reserveLeading;
|
|
557
|
+
final KasyMenuSelectionMode selectionMode;
|
|
558
|
+
final bool menuCloseOnSelect;
|
|
559
|
+
final VoidCallback? onClose;
|
|
560
|
+
|
|
561
|
+
/// Shared [TapRegion] group (from the presenter), so this row's submenu flyout
|
|
562
|
+
/// is "inside" the menu and doesn't dismiss the parent when tapped.
|
|
563
|
+
final Object? tapGroupId;
|
|
564
|
+
|
|
565
|
+
const _MenuItemRow({
|
|
566
|
+
required this.item,
|
|
567
|
+
required this.compact,
|
|
568
|
+
required this.reserveLeading,
|
|
569
|
+
required this.selectionMode,
|
|
570
|
+
required this.menuCloseOnSelect,
|
|
571
|
+
this.onClose,
|
|
572
|
+
this.tapGroupId,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
@override
|
|
576
|
+
State<_MenuItemRow> createState() => _MenuItemRowState();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
class _MenuItemRowState extends State<_MenuItemRow>
|
|
580
|
+
with SingleTickerProviderStateMixin {
|
|
581
|
+
// Submenu flyout machinery — created only when the row has a submenu. It rides
|
|
582
|
+
// the same LayerLink + OverlayPortal mechanism as [KasyMenuAnchor] so the
|
|
583
|
+
// nested menu stays glued to this row (compositor level) and opens to its
|
|
584
|
+
// side, like a desktop cascade, even inside the scaled device-preview frame.
|
|
585
|
+
static const double _submenuWidth = 224;
|
|
586
|
+
final LayerLink _link = LayerLink();
|
|
587
|
+
final OverlayPortalController _portal = OverlayPortalController();
|
|
588
|
+
AnimationController? _anim;
|
|
589
|
+
// Assigned in initState only for submenu rows; read only from the submenu
|
|
590
|
+
// overlay, which exists solely on that path.
|
|
591
|
+
late final Animation<double> _fade;
|
|
592
|
+
late final Animation<double> _scale;
|
|
593
|
+
|
|
594
|
+
// Whether the flyout opens to the left of the row (no room on the right).
|
|
595
|
+
bool _openLeft = false;
|
|
596
|
+
// Whether the submenu is expanded inline beneath the row (the narrow-screen
|
|
597
|
+
// accordion fallback, when there's no room for a side cascade).
|
|
598
|
+
bool _inlineExpanded = false;
|
|
599
|
+
|
|
600
|
+
bool get _hasSubmenu => widget.item.submenu != null;
|
|
601
|
+
|
|
602
|
+
@override
|
|
603
|
+
void initState() {
|
|
604
|
+
super.initState();
|
|
605
|
+
if (_hasSubmenu) {
|
|
606
|
+
_anim = AnimationController(
|
|
607
|
+
vsync: this,
|
|
608
|
+
duration: const Duration(milliseconds: 150),
|
|
609
|
+
);
|
|
610
|
+
_fade = CurvedAnimation(parent: _anim!, curve: Curves.easeOut);
|
|
611
|
+
_scale = Tween<double>(begin: 0.96, end: 1.0).animate(
|
|
612
|
+
CurvedAnimation(parent: _anim!, curve: Curves.easeOutCubic),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
@override
|
|
618
|
+
void dispose() {
|
|
619
|
+
_anim?.dispose();
|
|
620
|
+
super.dispose();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Toggle the submenu. With room on a side it cascades as a flyout (desktop);
|
|
624
|
+
// on a narrow screen it expands inline beneath the row (the accordion the
|
|
625
|
+
// NN/g mobile-subnavigation guidance recommends for a handful of items —
|
|
626
|
+
// lowest interaction cost, no disorienting second surface).
|
|
627
|
+
void _toggleSubmenu() {
|
|
628
|
+
if (_portal.isShowing) {
|
|
629
|
+
_closeSubmenu();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (_inlineExpanded) {
|
|
633
|
+
setState(() => _inlineExpanded = false);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
final _FlyoutSide side = _resolveSide();
|
|
637
|
+
if (side == _FlyoutSide.none) {
|
|
638
|
+
setState(() => _inlineExpanded = true);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
setState(() => _openLeft = side == _FlyoutSide.left);
|
|
642
|
+
_portal.show();
|
|
643
|
+
_anim!.forward(from: 0);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
void _closeSubmenu() {
|
|
647
|
+
_anim!.reverse().then((_) {
|
|
648
|
+
if (mounted) _portal.hide();
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Pick the side with room for the flyout, or [none] when neither fits (the
|
|
653
|
+
// caller then falls back to a sheet). Position is the LayerLink's job — this
|
|
654
|
+
// only decides the side, measured in the overlay's space.
|
|
655
|
+
_FlyoutSide _resolveSide() {
|
|
656
|
+
final RenderBox? box = context.findRenderObject() as RenderBox?;
|
|
657
|
+
final RenderBox? overlayBox =
|
|
658
|
+
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
659
|
+
if (box == null || overlayBox == null) return _FlyoutSide.right;
|
|
660
|
+
final Offset topLeft = box.localToGlobal(Offset.zero, ancestor: overlayBox);
|
|
661
|
+
final double spaceRight =
|
|
662
|
+
overlayBox.size.width - (topLeft.dx + box.size.width);
|
|
663
|
+
final double spaceLeft = topLeft.dx;
|
|
664
|
+
const double needed = _submenuWidth + 12;
|
|
665
|
+
if (spaceRight >= needed) return _FlyoutSide.right;
|
|
666
|
+
if (spaceLeft >= needed) return _FlyoutSide.left;
|
|
667
|
+
return _FlyoutSide.none;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
void _handleTap() {
|
|
671
|
+
if (_hasSubmenu) {
|
|
672
|
+
_toggleSubmenu();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// Close first (mirrors the call-site ordering that avoids a Navigator lock
|
|
676
|
+
// when the action triggers a rebuild), then run the action. An overlay-based
|
|
677
|
+
// menu ([KasyMenuAnchor]) supplies onClose; a route-based one pops itself.
|
|
678
|
+
final bool close = widget.item.closeOnSelect ?? widget.menuCloseOnSelect;
|
|
679
|
+
if (close) {
|
|
680
|
+
_bubbleClose();
|
|
681
|
+
}
|
|
682
|
+
widget.item.onTap?.call();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Dismiss the whole menu chain. Overlay-based menus pass an onClose; a
|
|
686
|
+
// route-based menu pops the navigator itself.
|
|
687
|
+
void _bubbleClose() {
|
|
688
|
+
if (widget.onClose != null) {
|
|
689
|
+
widget.onClose!();
|
|
690
|
+
} else {
|
|
691
|
+
Navigator.of(context, rootNavigator: true).pop();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Given to the submenu: when one of its rows closes the menu, fold the flyout
|
|
696
|
+
// away and bubble the close up so the entire chain dismisses together.
|
|
697
|
+
void _closeChain() {
|
|
698
|
+
if (_portal.isShowing) _closeSubmenu();
|
|
699
|
+
_bubbleClose();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
Widget _buildSubmenuOverlay(BuildContext context) {
|
|
703
|
+
// Anchor the flyout to this row's side: right edge → left edge (or mirrored
|
|
704
|
+
// when flipped). Nudge up by the panel inset so the first submenu row lines
|
|
705
|
+
// up with the tapped row. The LayerLink does the positioning.
|
|
706
|
+
final Alignment target = _openLeft ? Alignment.topLeft : Alignment.topRight;
|
|
707
|
+
final Alignment follower =
|
|
708
|
+
_openLeft ? Alignment.topRight : Alignment.topLeft;
|
|
709
|
+
final Offset offset = _openLeft
|
|
710
|
+
? const Offset(-KasySpacing.xs, -KasySpacing.xs)
|
|
711
|
+
: const Offset(KasySpacing.xs, -KasySpacing.xs);
|
|
712
|
+
final Size viewport = MediaQuery.sizeOf(context);
|
|
713
|
+
|
|
714
|
+
return TapRegion(
|
|
715
|
+
groupId: widget.tapGroupId,
|
|
716
|
+
onTapOutside: (_) => _closeSubmenu(),
|
|
717
|
+
child: Stack(
|
|
718
|
+
children: [
|
|
719
|
+
CompositedTransformFollower(
|
|
720
|
+
link: _link,
|
|
721
|
+
targetAnchor: target,
|
|
722
|
+
followerAnchor: follower,
|
|
723
|
+
offset: offset,
|
|
724
|
+
child: FadeTransition(
|
|
725
|
+
opacity: _fade,
|
|
726
|
+
child: ScaleTransition(
|
|
727
|
+
scale: _scale,
|
|
728
|
+
alignment: follower,
|
|
729
|
+
child: KasyPopoverSurface(
|
|
730
|
+
width: _submenuWidth,
|
|
731
|
+
maxHeight: viewport.height * 0.8,
|
|
732
|
+
child: KasyMenu(
|
|
733
|
+
sections: widget.item.submenu!,
|
|
734
|
+
closeOnSelect: widget.menuCloseOnSelect,
|
|
735
|
+
onClose: _closeChain,
|
|
736
|
+
tapGroupId: widget.tapGroupId,
|
|
737
|
+
),
|
|
738
|
+
),
|
|
739
|
+
),
|
|
740
|
+
),
|
|
741
|
+
),
|
|
742
|
+
],
|
|
743
|
+
),
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
@override
|
|
748
|
+
Widget build(BuildContext context) {
|
|
749
|
+
final KasyMenuItem item = widget.item;
|
|
750
|
+
final bool compact = widget.compact;
|
|
751
|
+
final KasyMenuSelectionMode selectionMode = widget.selectionMode;
|
|
752
|
+
final KasyColors c = context.colors;
|
|
753
|
+
final bool disabled = !item.enabled;
|
|
754
|
+
final bool danger = item.tone == KasyMenuItemTone.danger;
|
|
755
|
+
|
|
756
|
+
final Color baseColor = danger ? c.error : c.onSurface;
|
|
757
|
+
final Color fg = item.selected ? c.primary : baseColor;
|
|
758
|
+
final Color resolvedFg = disabled
|
|
759
|
+
? Color.alphaBlend(fg.withValues(alpha: 0.45), c.surface)
|
|
760
|
+
: fg;
|
|
761
|
+
final Color leadColor = item.selected
|
|
762
|
+
? c.primary
|
|
763
|
+
: (danger ? c.error : c.muted);
|
|
764
|
+
final Color resolvedLead = disabled
|
|
765
|
+
? Color.alphaBlend(leadColor.withValues(alpha: 0.45), c.surface)
|
|
766
|
+
: leadColor;
|
|
767
|
+
|
|
768
|
+
final double iconSize = compact ? KasyIconSize.sm : KasyIconSize.lg;
|
|
769
|
+
final double iconGap = compact ? KasySpacing.smd : KasySpacing.sm;
|
|
770
|
+
final EdgeInsets itemPadding = EdgeInsets.symmetric(
|
|
771
|
+
horizontal: KasySpacing.smd,
|
|
772
|
+
vertical: compact ? 6 : KasySpacing.smd,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
final TextStyle? titleStyle =
|
|
776
|
+
(compact ? context.textTheme.bodyMedium : context.textTheme.bodyLarge)
|
|
777
|
+
?.copyWith(
|
|
778
|
+
color: resolvedFg,
|
|
779
|
+
fontWeight: item.selected ? FontWeight.w600 : FontWeight.w500,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// Prefix slot: an icon, a selection indicator (check for multi-select, a
|
|
783
|
+
// filled dot for single-select), or reserved empty space so the group's
|
|
784
|
+
// labels stay aligned.
|
|
785
|
+
Widget? leading;
|
|
786
|
+
if (item.icon != null) {
|
|
787
|
+
leading = Icon(item.icon, size: iconSize, color: resolvedLead);
|
|
788
|
+
} else if (item.selected) {
|
|
789
|
+
leading = selectionMode == KasyMenuSelectionMode.single
|
|
790
|
+
? SizedBox(
|
|
791
|
+
width: iconSize,
|
|
792
|
+
height: iconSize,
|
|
793
|
+
child: Center(
|
|
794
|
+
child: Container(
|
|
795
|
+
width: 7,
|
|
796
|
+
height: 7,
|
|
797
|
+
decoration: BoxDecoration(
|
|
798
|
+
color: c.primary,
|
|
799
|
+
shape: BoxShape.circle,
|
|
800
|
+
),
|
|
801
|
+
),
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
: Icon(KasyIcons.check, size: iconSize, color: c.primary);
|
|
805
|
+
} else if (widget.reserveLeading) {
|
|
806
|
+
leading = SizedBox(width: iconSize);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Suffix slot: submenu chevron, custom trailing, or a shortcut chip. The
|
|
810
|
+
// chevron rotates a quarter turn down while the submenu is expanded inline,
|
|
811
|
+
// so the row reads like an accordion header.
|
|
812
|
+
Widget? trailing;
|
|
813
|
+
if (item.submenu != null) {
|
|
814
|
+
trailing = AnimatedRotation(
|
|
815
|
+
turns: _inlineExpanded ? 0.25 : 0,
|
|
816
|
+
duration: const Duration(milliseconds: 150),
|
|
817
|
+
curve: Curves.easeOutCubic,
|
|
818
|
+
child: Icon(
|
|
819
|
+
KasyIcons.chevronRight,
|
|
820
|
+
size: KasyIconSize.sm,
|
|
821
|
+
color: resolvedLead,
|
|
822
|
+
),
|
|
823
|
+
);
|
|
824
|
+
} else if (item.trailing != null) {
|
|
825
|
+
trailing = item.trailing;
|
|
826
|
+
} else if (item.shortcut != null) {
|
|
827
|
+
trailing = _MenuShortcut(label: item.shortcut!, compact: compact);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
final bool hasDescription =
|
|
831
|
+
item.description != null && item.description!.isNotEmpty;
|
|
832
|
+
// With a description the row is two lines; align the icon and shortcut to the
|
|
833
|
+
// title line (a 2px nudge centers a 16px glyph on the 20px title), as the
|
|
834
|
+
// HeroUI menu does. Single-line rows stay vertically centered.
|
|
835
|
+
Widget alignToTitle(Widget w) => hasDescription
|
|
836
|
+
? Padding(padding: const EdgeInsets.only(top: 2), child: w)
|
|
837
|
+
: w;
|
|
838
|
+
|
|
839
|
+
final Widget row = Row(
|
|
840
|
+
crossAxisAlignment: hasDescription
|
|
841
|
+
? CrossAxisAlignment.start
|
|
842
|
+
: CrossAxisAlignment.center,
|
|
843
|
+
children: [
|
|
844
|
+
if (leading != null) ...[
|
|
845
|
+
alignToTitle(leading),
|
|
846
|
+
SizedBox(width: iconGap),
|
|
847
|
+
],
|
|
848
|
+
Expanded(
|
|
849
|
+
child: Column(
|
|
850
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
851
|
+
mainAxisSize: MainAxisSize.min,
|
|
852
|
+
children: [
|
|
853
|
+
Text(item.title, style: titleStyle),
|
|
854
|
+
if (hasDescription)
|
|
855
|
+
Text(
|
|
856
|
+
item.description!,
|
|
857
|
+
style: context.textTheme.bodySmall?.copyWith(color: c.muted),
|
|
858
|
+
),
|
|
859
|
+
],
|
|
860
|
+
),
|
|
861
|
+
),
|
|
862
|
+
if (trailing != null) ...[
|
|
863
|
+
SizedBox(width: iconGap),
|
|
864
|
+
alignToTitle(trailing),
|
|
865
|
+
],
|
|
866
|
+
],
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
if (disabled) {
|
|
870
|
+
return Padding(padding: itemPadding, child: row);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
final Widget hover = KasyHover(
|
|
874
|
+
onTap: _handleTap,
|
|
875
|
+
focusable: true,
|
|
876
|
+
semanticLabel: item.title,
|
|
877
|
+
focusGapColor: c.surface,
|
|
878
|
+
borderRadius: BorderRadius.circular(KasyRadius.rounded2_5xl),
|
|
879
|
+
hoverColor: item.selected ? c.accentSoft : c.surfaceSecondary,
|
|
880
|
+
pressColor: danger ? c.error : c.primary,
|
|
881
|
+
padding: itemPadding,
|
|
882
|
+
child: ConstrainedBox(
|
|
883
|
+
constraints: BoxConstraints(minHeight: compact ? 24 : 32),
|
|
884
|
+
child: Align(alignment: Alignment.centerLeft, child: row),
|
|
885
|
+
),
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
// A plain row needs no overlay machinery.
|
|
889
|
+
if (!_hasSubmenu) return hover;
|
|
890
|
+
|
|
891
|
+
// The trigger carries the LayerLink so a side cascade can anchor to it.
|
|
892
|
+
Widget child = CompositedTransformTarget(link: _link, child: hover);
|
|
893
|
+
|
|
894
|
+
// Narrow screens expand the submenu inline, indented beneath the row (so the
|
|
895
|
+
// nested items align under the title). Mutually exclusive with the cascade.
|
|
896
|
+
if (_inlineExpanded) {
|
|
897
|
+
final double indent = iconSize + iconGap;
|
|
898
|
+
child = Column(
|
|
899
|
+
mainAxisSize: MainAxisSize.min,
|
|
900
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
901
|
+
children: [
|
|
902
|
+
child,
|
|
903
|
+
Padding(
|
|
904
|
+
padding: EdgeInsets.only(left: indent),
|
|
905
|
+
child: KasyMenu(
|
|
906
|
+
sections: item.submenu!,
|
|
907
|
+
density: widget.compact
|
|
908
|
+
? KasyMenuDensity.compact
|
|
909
|
+
: KasyMenuDensity.comfortable,
|
|
910
|
+
closeOnSelect: widget.menuCloseOnSelect,
|
|
911
|
+
onClose: _closeChain,
|
|
912
|
+
tapGroupId: widget.tapGroupId,
|
|
913
|
+
),
|
|
914
|
+
),
|
|
915
|
+
],
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// A submenu row hosts its side flyout through an OverlayPortal.
|
|
920
|
+
return OverlayPortal(
|
|
921
|
+
controller: _portal,
|
|
922
|
+
overlayChildBuilder: _buildSubmenuOverlay,
|
|
923
|
+
child: child,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|