kasy-cli 1.39.0 → 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 (33) 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/AGENTS.md +2 -2
  5. package/templates/firebase/DESIGN_SYSTEM.md +3 -2
  6. package/templates/firebase/lib/components/components.dart +6 -1
  7. package/templates/firebase/lib/components/kasy_app_bar.dart +11 -1
  8. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
  9. package/templates/firebase/lib/components/kasy_menu.dart +902 -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/bottom_menu/web_content_wrapper.dart +23 -7
  13. package/templates/firebase/lib/core/chrome/app_bar_config.dart +1 -1
  14. package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
  15. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +96 -14
  16. package/templates/firebase/lib/core/theme/texts.dart +25 -0
  17. package/templates/firebase/lib/core/web_viewport_scale.dart +8 -7
  18. package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
  19. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +363 -13
  20. package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
  21. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
  22. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
  23. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
  24. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
  25. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
  26. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
  27. package/templates/firebase/lib/i18n/en.i18n.json +6 -2
  28. package/templates/firebase/lib/i18n/es.i18n.json +6 -2
  29. package/templates/firebase/lib/i18n/pt.i18n.json +6 -2
  30. package/templates/firebase/lib/router.dart +2 -0
  31. package/templates/firebase/pubspec.yaml +1 -1
  32. package/templates/firebase/test/app_bar_config_test.dart +70 -0
  33. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +0 -81
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2
2
  import 'package:go_router/go_router.dart';
3
3
  import 'package:kasy_kit/components/kasy_app_bar.dart';
4
4
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
5
+ import 'package:kasy_kit/core/navigation/kasy_route_observer.dart';
5
6
  import 'package:kasy_kit/core/theme/theme.dart';
6
7
  import 'package:kasy_kit/core/widgets/kasy_hover.dart';
7
8
  import 'package:kasy_kit/core/widgets/kasy_scroll_behavior.dart';
@@ -46,18 +47,34 @@ class HomeComponentsCatalog extends StatefulWidget {
46
47
  State<HomeComponentsCatalog> createState() => _HomeComponentsCatalogState();
47
48
  }
48
49
 
49
- class _HomeComponentsCatalogState extends State<HomeComponentsCatalog> {
50
+ class _HomeComponentsCatalogState extends State<HomeComponentsCatalog>
51
+ with RouteAware {
50
52
  final TextEditingController _searchCtrl = TextEditingController();
51
53
  String _query = '';
52
54
 
55
+ @override
56
+ void didChangeDependencies() {
57
+ super.didChangeDependencies();
58
+ final ModalRoute<dynamic>? route = ModalRoute.of(context);
59
+ if (route != null) {
60
+ kasyRouteObserver.subscribe(this, route);
61
+ }
62
+ }
63
+
53
64
  @override
54
65
  void dispose() {
66
+ kasyRouteObserver.unsubscribe(this);
55
67
  _searchCtrl.dispose();
56
68
  super.dispose();
57
69
  }
58
70
 
59
- /// Reset the filter when leaving for a component, so coming back shows the
60
- /// full catalog instead of the stale search term.
71
+ /// When a pushed preview pops back to the catalog, reset the filter so the
72
+ /// list shows the full catalog instead of the stale search term. Backed up by
73
+ /// the navigation future's [_clearSearch] for the admin-embedded case, whose
74
+ /// nested navigator the root observer does not see.
75
+ @override
76
+ void didPopNext() => _clearSearch();
77
+
61
78
  void _clearSearch() {
62
79
  if (!mounted) return;
63
80
  if (_query.isEmpty && _searchCtrl.text.isEmpty) return;
@@ -325,6 +342,7 @@ const Set<String> _kReadyComponents = <String>{
325
342
  'Dialog',
326
343
  'DropDown',
327
344
  'Hover',
345
+ 'Menu',
328
346
  'Tabs',
329
347
  'TextArea',
330
348
  'TextField',
@@ -352,6 +370,7 @@ const Set<String> _kWebReadyComponents = <String>{
352
370
  'Dialog',
353
371
  'DropDown',
354
372
  'Hover',
373
+ 'Menu',
355
374
  'Sidebar',
356
375
  'Skeleton',
357
376
  'Status Tag',
@@ -384,6 +403,7 @@ const List<_CatalogRow> _kCatalog = [
384
403
  _CatalogRow('Dialog'),
385
404
  _CatalogRow('DropDown'),
386
405
  _CatalogRow('Hover'),
406
+ _CatalogRow('Menu'),
387
407
  _CatalogRow('Sidebar'),
388
408
  _CatalogRow('Skeleton'),
389
409
  _CatalogRow('Status Tag'),
@@ -412,7 +432,6 @@ const List<_CatalogRow> _kCatalog = [
412
432
  _CatalogRow('LineChart'),
413
433
  _CatalogRow('LinkButton'),
414
434
  _CatalogRow('ListGroup'),
415
- _CatalogRow('Menu'),
416
435
  _CatalogRow('NumberField'),
417
436
  _CatalogRow('NumberStepper'),
418
437
  _CatalogRow('NumberValue'),
@@ -101,6 +101,15 @@ ComponentPreviewDefinition? getComponentPreviewDefinition(
101
101
  label: 'Application no avatar (desktop)',
102
102
  builder: _buildAppBarApplicationNoAvatarVariant,
103
103
  ),
104
+ // Per-screen presets — same bar, a screen picking its own chrome.
105
+ ComponentPreviewVariant(
106
+ label: 'Search only (desktop)',
107
+ builder: _buildAppBarApplicationSearchOnlyVariant,
108
+ ),
109
+ ComponentPreviewVariant(
110
+ label: 'Notifications only (desktop)',
111
+ builder: _buildAppBarApplicationNotificationsOnlyVariant,
112
+ ),
104
113
  ],
105
114
  );
106
115
  case 'Button':
@@ -323,6 +332,21 @@ ComponentPreviewDefinition? getComponentPreviewDefinition(
323
332
  ),
324
333
  ],
325
334
  );
335
+ case 'Menu':
336
+ return const ComponentPreviewDefinition(
337
+ title: 'Menu',
338
+ variants: [
339
+ ComponentPreviewVariant(
340
+ label: 'Basic usage',
341
+ builder: _buildMenuBasicUsage,
342
+ ),
343
+ ComponentPreviewVariant(
344
+ label: 'Selection',
345
+ builder: _buildMenuStyles,
346
+ ),
347
+ ComponentPreviewVariant(label: 'Submenu', builder: _buildMenuSubmenu),
348
+ ],
349
+ );
326
350
  case 'Badge':
327
351
  return const ComponentPreviewDefinition(
328
352
  title: 'Badge',
@@ -1942,16 +1966,42 @@ Widget _buildAppBarMenuVariant(BuildContext context) {
1942
1966
  );
1943
1967
  }
1944
1968
 
1969
+ /// A fully-wired desktop config for previews — every element active, so each
1970
+ /// variant can switch one thing on/off and read clearly.
1971
+ KasyAppBarConfig _demoAppBarConfig() => KasyAppBarConfig(
1972
+ onToggleTheme: () {},
1973
+ onNotifications: () {},
1974
+ onCreate: () {},
1975
+ onAvatarTap: () {},
1976
+ );
1977
+
1945
1978
  Widget _buildAppBarApplicationVariant(BuildContext context) {
1946
- return const _ApplicationBarPreview();
1979
+ return _ApplicationBarPreview(config: _demoAppBarConfig());
1947
1980
  }
1948
1981
 
1949
1982
  Widget _buildAppBarApplicationBadgeVariant(BuildContext context) {
1950
- return const _ApplicationBarPreview(showBadge: true);
1983
+ return _ApplicationBarPreview(
1984
+ config: _demoAppBarConfig().copyWith(showNotificationBadge: true),
1985
+ );
1951
1986
  }
1952
1987
 
1953
1988
  Widget _buildAppBarApplicationNoAvatarVariant(BuildContext context) {
1954
- return const _ApplicationBarPreview(showAvatar: false);
1989
+ return _ApplicationBarPreview(
1990
+ config: _demoAppBarConfig().copyWith(showAvatar: false),
1991
+ );
1992
+ }
1993
+
1994
+ // Per-screen presets: the same component, different chrome chosen by a screen.
1995
+ Widget _buildAppBarApplicationSearchOnlyVariant(BuildContext context) {
1996
+ return _ApplicationBarPreview(
1997
+ config: _demoAppBarConfig().only(search: true, themeToggle: true),
1998
+ );
1999
+ }
2000
+
2001
+ Widget _buildAppBarApplicationNotificationsOnlyVariant(BuildContext context) {
2002
+ return _ApplicationBarPreview(
2003
+ config: _demoAppBarConfig().only(notifications: true, themeToggle: true),
2004
+ );
1955
2005
  }
1956
2006
 
1957
2007
  Widget _buildButtonSizesVariant(BuildContext context) {
@@ -3884,10 +3934,11 @@ class _AccordionPreviewState extends State<_AccordionPreview> {
3884
3934
  /// bar + faux sidebar + content) so the preview reads as the desktop chrome it
3885
3935
  /// is — the responsive desktop half of [KasyAppBar].
3886
3936
  class _ApplicationBarPreview extends StatelessWidget {
3887
- final bool showBadge;
3888
- final bool showAvatar;
3937
+ /// The exact config a screen would publish — rendered through the same
3938
+ /// [KasyAppBar.fromConfig] the shell uses, so the preview mirrors real usage.
3939
+ final KasyAppBarConfig config;
3889
3940
 
3890
- const _ApplicationBarPreview({this.showBadge = false, this.showAvatar = true});
3941
+ const _ApplicationBarPreview({required this.config});
3891
3942
 
3892
3943
  /// Width the mock window is laid out at. The application bar is desktop chrome
3893
3944
  /// (220px search + actions), so it needs a desktop-class width — we render at
@@ -3916,13 +3967,7 @@ class _ApplicationBarPreview extends StatelessWidget {
3916
3967
  children: [
3917
3968
  const _BrowserTopBar(),
3918
3969
  // The real bar, flush — no corner radius of its own.
3919
- KasyAppBar.application(
3920
- showNotificationBadge: showBadge,
3921
- showAvatar: showAvatar,
3922
- onNotifications: () {},
3923
- onCreate: () {},
3924
- onAvatarTap: () {},
3925
- ),
3970
+ KasyAppBar.fromConfig(config),
3926
3971
  const SizedBox(
3927
3972
  height: 150,
3928
3973
  child: Row(
@@ -5387,6 +5432,311 @@ Widget _previewIntrinsicLaunch(Widget child) {
5387
5432
  );
5388
5433
  }
5389
5434
 
5435
+ // ── Menu (KasyMenu) previews ─────────────────────────────────────────────────
5436
+ // Each preset is triggered by a button — a real menu opens, anchored to it.
5437
+ // Most open as the default floating menu (popover, both platforms); one opens as
5438
+ // a bottom sheet to show the opt-in presentation.
5439
+
5440
+ // Basic usage menu — mirrors the HeroUI "Actions / Danger zone" dropdown
5441
+ // (Figma 17840:22571): grouped rows with an icon, title + description and a
5442
+ // keyboard shortcut, plus a destructive "Danger zone". The kit has no pencil
5443
+ // glyph yet, so Edit file borrows the note icon.
5444
+ const List<KasyMenuSection> _kMenuActions = [
5445
+ KasyMenuSection(
5446
+ label: 'Actions',
5447
+ items: [
5448
+ KasyMenuItem(
5449
+ icon: KasyIcons.add,
5450
+ title: 'New file',
5451
+ description: 'Create a new file',
5452
+ shortcut: '⌘ N',
5453
+ onTap: _triggerTapFeedback,
5454
+ ),
5455
+ KasyMenuItem(
5456
+ icon: KasyIcons.copy,
5457
+ title: 'Copy link',
5458
+ description: 'Copy the file link',
5459
+ shortcut: '⌘ L',
5460
+ onTap: _triggerTapFeedback,
5461
+ ),
5462
+ KasyMenuItem(
5463
+ icon: KasyIcons.note,
5464
+ title: 'Edit file',
5465
+ description: 'Make changes',
5466
+ shortcut: '⌘ E',
5467
+ onTap: _triggerTapFeedback,
5468
+ ),
5469
+ ],
5470
+ ),
5471
+ KasyMenuSection(
5472
+ label: 'Danger zone',
5473
+ items: [
5474
+ KasyMenuItem(
5475
+ icon: KasyIcons.trash,
5476
+ title: 'Delete file',
5477
+ description: 'Move to trash',
5478
+ shortcut: '⌘ ⇧ K',
5479
+ tone: KasyMenuItemTone.danger,
5480
+ onTap: _triggerTapFeedback,
5481
+ ),
5482
+ ],
5483
+ ),
5484
+ ];
5485
+
5486
+ const List<KasyMenuSection> _kMenuSubmenu = [
5487
+ KasyMenuSection(
5488
+ items: [
5489
+ KasyMenuItem(
5490
+ icon: KasyIcons.share,
5491
+ title: 'Share',
5492
+ submenu: [
5493
+ KasyMenuSection(
5494
+ items: [
5495
+ KasyMenuItem(title: 'WhatsApp', onTap: _triggerTapFeedback),
5496
+ KasyMenuItem(title: 'Telegram', onTap: _triggerTapFeedback),
5497
+ KasyMenuItem(title: 'Email', onTap: _triggerTapFeedback),
5498
+ ],
5499
+ ),
5500
+ ],
5501
+ ),
5502
+ KasyMenuItem(
5503
+ icon: KasyIcons.copy,
5504
+ title: 'Copy link',
5505
+ onTap: _triggerTapFeedback,
5506
+ ),
5507
+ ],
5508
+ ),
5509
+ ];
5510
+
5511
+
5512
+ // Layout shared by the two interactive menu demos: the trigger floats centered
5513
+ // in the open area (so the menu has room to anchor against it) and a toggle is
5514
+ // pinned at the bottom, just above the variant title. Height is a slice of the
5515
+ // viewport, clamped, and kept under the preview height so it still centers.
5516
+ class _MenuDemoFrame extends StatelessWidget {
5517
+ final Widget trigger;
5518
+ final String controlTitle;
5519
+ final String controlSubtitle;
5520
+ final bool controlValue;
5521
+ final ValueChanged<bool> onControlChanged;
5522
+
5523
+ const _MenuDemoFrame({
5524
+ required this.trigger,
5525
+ required this.controlTitle,
5526
+ required this.controlSubtitle,
5527
+ required this.controlValue,
5528
+ required this.onControlChanged,
5529
+ });
5530
+
5531
+ @override
5532
+ Widget build(BuildContext context) {
5533
+ // Give the demo real vertical room so the control row can sit near the
5534
+ // bottom (like a settings row) instead of crowding under the trigger. The
5535
+ // trigger still lives in a plain centering Row — the menu anchors to its
5536
+ // render box via LayerLink, so the surrounding height never moves the menu.
5537
+ final double viewportHeight = MediaQuery.sizeOf(context).height;
5538
+ final double frameHeight = (viewportHeight - 260).clamp(320.0, 520.0);
5539
+ return SizedBox(
5540
+ height: frameHeight,
5541
+ child: Column(
5542
+ children: [
5543
+ const Spacer(flex: 4),
5544
+ Row(mainAxisAlignment: MainAxisAlignment.center, children: [trigger]),
5545
+ const Spacer(flex: 6),
5546
+ Row(
5547
+ children: [
5548
+ Expanded(
5549
+ child: Column(
5550
+ crossAxisAlignment: CrossAxisAlignment.start,
5551
+ mainAxisSize: MainAxisSize.min,
5552
+ children: [
5553
+ Text(
5554
+ controlTitle,
5555
+ style: context.textTheme.titleSmall?.copyWith(
5556
+ color: context.colors.onSurface,
5557
+ ),
5558
+ ),
5559
+ Text(
5560
+ controlSubtitle,
5561
+ style: context.textTheme.bodySmall?.copyWith(
5562
+ color: context.colors.muted,
5563
+ ),
5564
+ ),
5565
+ ],
5566
+ ),
5567
+ ),
5568
+ // The same switch the Settings screen uses: adaptive (Cupertino on
5569
+ // iOS, Material elsewhere — identical look per platform), scaled
5570
+ // down, themed to the primary color.
5571
+ Transform.scale(
5572
+ scale: 0.82,
5573
+ child: Switch.adaptive(
5574
+ value: controlValue,
5575
+ onChanged: onControlChanged,
5576
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
5577
+ ),
5578
+ ),
5579
+ ],
5580
+ ),
5581
+ ],
5582
+ ),
5583
+ );
5584
+ }
5585
+ }
5586
+
5587
+ Widget _buildMenuBasicUsage(BuildContext context) => const _MenuBasicUsageDemo();
5588
+
5589
+ // First preview: the trigger + a "Bottom Sheet" toggle. Off → the menu opens as
5590
+ // a popover anchored to the button; on → it slides up as a bottom sheet. The
5591
+ // same compact menu either way — only the container changes.
5592
+ class _MenuBasicUsageDemo extends StatefulWidget {
5593
+ const _MenuBasicUsageDemo();
5594
+
5595
+ @override
5596
+ State<_MenuBasicUsageDemo> createState() => _MenuBasicUsageDemoState();
5597
+ }
5598
+
5599
+ class _MenuBasicUsageDemoState extends State<_MenuBasicUsageDemo> {
5600
+ bool _bottomSheet = false;
5601
+
5602
+ @override
5603
+ Widget build(BuildContext context) {
5604
+ return _MenuDemoFrame(
5605
+ controlTitle: 'Bottom Sheet',
5606
+ controlSubtitle: 'Toggle bottom sheet presentation',
5607
+ controlValue: _bottomSheet,
5608
+ onControlChanged: (bool v) => setState(() => _bottomSheet = v),
5609
+ trigger: KasyMenuAnchor(
5610
+ sections: _kMenuActions,
5611
+ // Wider panel: rows carry a title + description (HeroUI menu).
5612
+ width: 288,
5613
+ presentation: _bottomSheet
5614
+ ? KasyMenuPresentation.bottomSheet
5615
+ : KasyMenuPresentation.popover,
5616
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5617
+ label: 'Actions',
5618
+ variant: KasyButtonVariant.soft,
5619
+ onPressed: () {
5620
+ _triggerTapFeedback();
5621
+ open();
5622
+ },
5623
+ ),
5624
+ ),
5625
+ );
5626
+ }
5627
+ }
5628
+
5629
+ Widget _buildMenuStyles(BuildContext context) => const _MenuSelectionDemo();
5630
+
5631
+ // Second preview: a real, interactive selection menu. Tapping a "Text Style"
5632
+ // row toggles its check (multi-select); tapping a "Text Alignment" row moves the
5633
+ // filled dot (single-select). The selection persists across reopens. A "Should
5634
+ // Close On Select" toggle controls whether picking an item dismisses the menu:
5635
+ // off → keep picking, tap outside to close (HeroUI editor menu); on → one pick
5636
+ // closes it.
5637
+ class _MenuSelectionDemo extends StatefulWidget {
5638
+ const _MenuSelectionDemo();
5639
+
5640
+ @override
5641
+ State<_MenuSelectionDemo> createState() => _MenuSelectionDemoState();
5642
+ }
5643
+
5644
+ class _MenuSelectionDemoState extends State<_MenuSelectionDemo> {
5645
+ // Multi-select set + single-select value, kept on the demo so reopening the
5646
+ // menu shows the last selection.
5647
+ final Set<String> _styles = <String>{'Bold', 'Underline'};
5648
+ String _align = 'Right';
5649
+ bool _closeOnSelect = false;
5650
+
5651
+ static const List<(String, String)> _styleItems = [
5652
+ ('Bold', '⌘ B'),
5653
+ ('Italic', '⌘ I'),
5654
+ ('Underline', '⌘ U'),
5655
+ ];
5656
+ static const List<(String, String)> _alignItems = [
5657
+ ('Left', '⌥ A'),
5658
+ ('Center', '⌥ H'),
5659
+ ('Right', '⌥ D'),
5660
+ ];
5661
+
5662
+ void _toggleStyle(String title) {
5663
+ setState(() {
5664
+ if (!_styles.remove(title)) _styles.add(title);
5665
+ });
5666
+ }
5667
+
5668
+ void _selectAlign(String title) => setState(() => _align = title);
5669
+
5670
+ // Built from state so each tap (via setState) rebuilds the anchor and the
5671
+ // open menu reflects the new selection in place.
5672
+ List<KasyMenuSection> _buildSections() => [
5673
+ KasyMenuSection(
5674
+ label: 'Text Style',
5675
+ selectionMode: KasyMenuSelectionMode.multiple,
5676
+ items: [
5677
+ for (final (String title, String shortcut) in _styleItems)
5678
+ KasyMenuItem(
5679
+ title: title,
5680
+ shortcut: shortcut,
5681
+ selected: _styles.contains(title),
5682
+ onTap: () => _toggleStyle(title),
5683
+ ),
5684
+ ],
5685
+ ),
5686
+ KasyMenuSection(
5687
+ label: 'Text Alignment',
5688
+ selectionMode: KasyMenuSelectionMode.single,
5689
+ items: [
5690
+ for (final (String title, String shortcut) in _alignItems)
5691
+ KasyMenuItem(
5692
+ title: title,
5693
+ shortcut: shortcut,
5694
+ selected: _align == title,
5695
+ onTap: () => _selectAlign(title),
5696
+ ),
5697
+ ],
5698
+ ),
5699
+ ];
5700
+
5701
+ @override
5702
+ Widget build(BuildContext context) {
5703
+ return _MenuDemoFrame(
5704
+ controlTitle: 'Should Close On Select',
5705
+ controlSubtitle: 'Toggle should close on select',
5706
+ controlValue: _closeOnSelect,
5707
+ onControlChanged: (bool v) => setState(() => _closeOnSelect = v),
5708
+ trigger: KasyMenuAnchor(
5709
+ sections: _buildSections(),
5710
+ closeOnSelect: _closeOnSelect,
5711
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5712
+ label: 'Text styles',
5713
+ variant: KasyButtonVariant.soft,
5714
+ onPressed: () {
5715
+ _triggerTapFeedback();
5716
+ open();
5717
+ },
5718
+ ),
5719
+ ),
5720
+ );
5721
+ }
5722
+ }
5723
+
5724
+ Widget _buildMenuSubmenu(BuildContext context) {
5725
+ return _previewIntrinsicLaunch(
5726
+ KasyMenuAnchor(
5727
+ sections: _kMenuSubmenu,
5728
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5729
+ label: 'Share',
5730
+ variant: KasyButtonVariant.soft,
5731
+ onPressed: () {
5732
+ _triggerTapFeedback();
5733
+ open();
5734
+ },
5735
+ ),
5736
+ ),
5737
+ );
5738
+ }
5739
+
5390
5740
  const List<KasyAccordionItemData> _kAccordionItems = <KasyAccordionItemData>[
5391
5741
  KasyAccordionItemData(
5392
5742
  icon: KasyIcons.send,
@@ -5,6 +5,7 @@ import 'package:kasy_kit/components/kasy_app_bar.dart';
5
5
  import 'package:kasy_kit/core/home_widgets/home_widget_mywidget_service.dart';
6
6
  import 'package:kasy_kit/core/theme/theme.dart';
7
7
  import 'package:kasy_kit/core/widgets/kasy_scroll_behavior.dart';
8
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
8
9
  import 'package:kasy_kit/features/settings/ui/widgets/admin_card.dart';
9
10
  import 'package:kasy_kit/i18n/translations.g.dart';
10
11
 
@@ -18,6 +19,9 @@ class AdminHomeWidgets extends ConsumerWidget {
18
19
  child: KasyOverlayScaffold(
19
20
  title: t.settings.admin.home_widgets_title,
20
21
  onBack: () => context.pop(),
22
+ // Contain + center the single utility card on desktop so it never
23
+ // stretches edge-to-edge, matching Notifications / Reminders.
24
+ maxContentWidth: kKasyContentMaxWidth,
21
25
  slivers: [
22
26
  SliverList.list(
23
27
  children: [
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
5
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:image_picker/image_picker.dart';
7
7
  import 'package:kasy_kit/components/kasy_avatar.dart';
8
- import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
8
+ import 'package:kasy_kit/components/kasy_menu.dart';
9
9
  import 'package:kasy_kit/core/data/entities/upload_result.dart';
10
10
  import 'package:kasy_kit/core/data/models/user.dart';
11
11
  import 'package:kasy_kit/core/data/repositories/user_repository.dart';
@@ -13,7 +13,6 @@ import 'package:kasy_kit/core/states/models/user_state.dart';
13
13
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
14
14
  import 'package:kasy_kit/core/theme/theme.dart';
15
15
  import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
16
- import 'package:kasy_kit/core/widgets/kasy_hover.dart';
17
16
  import 'package:kasy_kit/features/settings/ui/widgets/avatar_utils.dart';
18
17
  import 'package:kasy_kit/features/settings/ui/widgets/round_progress.dart';
19
18
  import 'package:kasy_kit/i18n/translations.g.dart';
@@ -121,69 +120,56 @@ class _EditableUserAvatarState extends ConsumerState<EditableUserAvatar> {
121
120
  BuildContext context,
122
121
  UserState userState,
123
122
  String? avatarPath,
124
- ) async {
123
+ ) {
125
124
  final userId = userState.user.idOrNull;
126
125
  final cachedUrl = userId == null ? null : getCachedAvatarUrl(userId);
127
126
  final hasAvatar =
128
127
  _hasAvatarUrl(avatarPath) ||
129
128
  _hasAvatarUrl(cachedUrl) ||
130
129
  temporaryAvatarBytes != null;
131
- final result = await _showAvatarBottomSheet(context, hasAvatar: hasAvatar);
132
- if (result == null || !mounted) return;
133
- switch (result) {
134
- case _AvatarAction.camera:
135
- final file = await ImagePicker().pickImage(source: ImageSource.camera);
136
- await _uploadTask(file, userState);
137
- case _AvatarAction.library:
138
- final file = await ImagePicker().pickImage(source: ImageSource.gallery);
139
- await _uploadTask(file, userState);
140
- case _AvatarAction.remove:
141
- await _removeAvatar(userState);
142
- }
143
- }
144
-
145
- Future<_AvatarAction?> _showAvatarBottomSheet(
146
- BuildContext context, {
147
- required bool hasAvatar,
148
- }) {
149
- return showKasyBottomSheet<_AvatarAction>(
130
+ final avatar = t.settings.avatar;
131
+ return showKasyMenu<void>(
150
132
  context: context,
151
- builder: (sheetContext) => KasySheetSurface(
152
- showDragHandle: false,
153
- child: Padding(
154
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
155
- child: Column(
156
- mainAxisSize: MainAxisSize.min,
157
- children: [
158
- _BottomSheetTile(
159
- icon: KasyIcons.cameraAlt,
160
- label: t.settings.avatar.take_photo,
161
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.camera),
162
- ),
163
- _BottomSheetTile(
164
- icon: KasyIcons.gallery,
165
- label: t.settings.avatar.choose_library,
166
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.library),
133
+ anchorContext: context,
134
+ desktopWidth: 248,
135
+ sections: [
136
+ KasyMenuSection(
137
+ items: [
138
+ KasyMenuItem(
139
+ icon: KasyIcons.cameraAlt,
140
+ title: avatar.take_photo,
141
+ onTap: () => _pickAndUpload(ImageSource.camera, userState),
142
+ ),
143
+ KasyMenuItem(
144
+ icon: KasyIcons.gallery,
145
+ title: avatar.choose_library,
146
+ onTap: () => _pickAndUpload(ImageSource.gallery, userState),
147
+ ),
148
+ ],
149
+ ),
150
+ // Destructive action lives in its own ("Danger zone") group, per the
151
+ // HeroUI menu guidance.
152
+ if (hasAvatar)
153
+ KasyMenuSection(
154
+ items: [
155
+ KasyMenuItem(
156
+ icon: KasyIcons.trash,
157
+ title: avatar.remove_photo,
158
+ tone: KasyMenuItemTone.danger,
159
+ onTap: () => _removeAvatar(userState),
167
160
  ),
168
- if (hasAvatar) ...[
169
- Divider(
170
- color: sheetContext.colors.onSurface.withValues(alpha: .08),
171
- height: 1,
172
- ),
173
- _BottomSheetTile(
174
- icon: KasyIcons.trash,
175
- label: t.settings.avatar.remove_photo,
176
- color: sheetContext.colors.error,
177
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.remove),
178
- ),
179
- ],
180
161
  ],
181
162
  ),
182
- ),
183
- ),
163
+ ],
184
164
  );
185
165
  }
186
166
 
167
+ Future<void> _pickAndUpload(ImageSource source, UserState userState) async {
168
+ final file = await ImagePicker().pickImage(source: source);
169
+ if (!mounted) return;
170
+ await _uploadTask(file, userState);
171
+ }
172
+
187
173
  Future<void> _uploadTask(XFile? file, UserState userState) async {
188
174
  if (file == null) return;
189
175
  final userId = userState.user.idOrNull;
@@ -301,48 +287,6 @@ class _EditableUserAvatarState extends ConsumerState<EditableUserAvatar> {
301
287
  }
302
288
  }
303
289
 
304
- enum _AvatarAction { camera, library, remove }
305
-
306
- class _BottomSheetTile extends StatelessWidget {
307
- final IconData? icon;
308
- final String label;
309
- final Color? color;
310
- final VoidCallback onTap;
311
-
312
- const _BottomSheetTile({
313
- this.icon,
314
- required this.label,
315
- this.color,
316
- required this.onTap,
317
- });
318
-
319
- @override
320
- Widget build(BuildContext context) {
321
- final Color fg = color ?? context.colors.onSurface;
322
- return KasyHover(
323
- onTap: onTap,
324
- focusable: true,
325
- focusGapColor: context.colors.surface,
326
- borderRadius: BorderRadius.circular(KasyRadius.sm),
327
- padding: const EdgeInsets.symmetric(
328
- horizontal: KasySpacing.md,
329
- vertical: KasySpacing.smd,
330
- ),
331
- child: Row(
332
- children: [
333
- if (icon != null) ...[
334
- Icon(icon, size: KasyIconSize.lg, color: fg),
335
- const SizedBox(width: KasySpacing.sm),
336
- ],
337
- Text(
338
- label,
339
- style: context.textTheme.bodyLarge?.copyWith(color: fg),
340
- ),
341
- ],
342
- ),
343
- );
344
- }
345
- }
346
290
 
347
291
  /// Resolves the avatar [ImageProvider] and renders a [KasyAvatar] with
348
292
  /// [KasyAvatarFallbackSurface.soft]. Firebase Storage URL is loaded async.