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.
Files changed (23) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/package.json +1 -1
  3. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +27 -27
  4. package/templates/firebase/lib/components/components.dart +2 -0
  5. package/templates/firebase/lib/components/kasy_accordion.dart +4 -1
  6. package/templates/firebase/lib/components/kasy_alert.dart +5 -2
  7. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
  8. package/templates/firebase/lib/components/kasy_dialog.dart +3 -1
  9. package/templates/firebase/lib/components/kasy_menu.dart +926 -0
  10. package/templates/firebase/lib/components/kasy_popover.dart +267 -0
  11. package/templates/firebase/lib/components/kasy_sidebar.dart +20 -10
  12. package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
  13. package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
  14. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +320 -0
  15. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
  16. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
  17. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
  18. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
  19. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
  20. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
  21. package/templates/firebase/lib/router.dart +2 -0
  22. package/templates/firebase/pubspec.yaml +1 -1
  23. 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
+ }