kasy-cli 1.39.1 → 1.40.0

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