kasy-cli 1.37.1 → 1.38.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 (49) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  3. package/lib/scaffold/backends/patch-base-hashes.json +2 -2
  4. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  7. package/templates/firebase/AGENTS.md +7 -1
  8. package/templates/firebase/DESIGN_SYSTEM.md +13 -0
  9. package/templates/firebase/lib/components/kasy_app_bar.dart +57 -16
  10. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  11. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  12. package/templates/firebase/lib/components/kasy_sidebar.dart +394 -25
  13. package/templates/firebase/lib/components/kasy_web_header.dart +11 -3
  14. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -213
  15. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  16. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -4
  17. package/templates/firebase/lib/core/chrome/web_header_scope.dart +20 -0
  18. package/templates/firebase/lib/core/data/api/user_api.dart +4 -0
  19. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  20. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  21. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  22. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  23. package/templates/firebase/lib/core/states/user_state_notifier.dart +41 -0
  24. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  25. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  26. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  27. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  28. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  29. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +498 -466
  30. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  31. package/templates/firebase/lib/features/home/home_components_page.dart +16 -6
  32. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +7 -0
  33. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +23 -13
  34. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -0
  35. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  36. package/templates/firebase/lib/features/settings/settings_page.dart +51 -38
  37. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +549 -376
  38. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  39. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +266 -141
  40. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +23 -30
  41. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +30 -49
  42. package/templates/firebase/lib/i18n/en.i18n.json +23 -9
  43. package/templates/firebase/lib/i18n/es.i18n.json +23 -9
  44. package/templates/firebase/lib/i18n/pt.i18n.json +23 -9
  45. package/templates/firebase/lib/router.dart +43 -25
  46. package/templates/firebase/pubspec.yaml +1 -1
  47. package/templates/firebase/test/admin_shell_chrome_test.dart +104 -0
  48. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  49. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -16,7 +16,10 @@ import 'package:kasy_kit/i18n/translations.g.dart';
16
16
  // ─────────────────────────────────────────────────────────────────────────────
17
17
 
18
18
  // Figma sidebar is 223 wide; we run a touch wider for breathing room.
19
- const double _kWidthOpen = 248.0;
19
+ /// Open (expanded) width of the rail. Exposed so hosts that present it as a
20
+ /// drawer can size the drawer to match (e.g. the admin console on mobile).
21
+ const double kasySidebarWidth = 248.0;
22
+ const double _kWidthOpen = kasySidebarWidth;
20
23
  const double _kWidthCollapsed = 64.0;
21
24
  const double _kPadH = 16.0; // px-4
22
25
  // Tighter horizontal gutter for the narrow collapsed rail, so a 64px rail keeps
@@ -208,11 +211,68 @@ enum KasySidebarCollapseMode {
208
211
  collapsed,
209
212
  }
210
213
 
214
+ /// One row in the sidebar's generic ([KasySidebar.items]) mode: an icon, a
215
+ /// label and a tap callback. Reuses the exact same row recipe as the app's real
216
+ /// navigation, so a host (e.g. the admin console) can drive the SAME sidebar
217
+ /// with its own screens instead of the Bart-connected app tabs.
218
+ ///
219
+ /// When [children] is non-empty the row becomes an expandable group (the same
220
+ /// dropdown recipe as the connected "Income" submenu): tapping the row toggles
221
+ /// the sub-items, and [onTap] is ignored. A leaf row uses [onTap] to navigate
222
+ /// and [selected] to show the active pill.
223
+ class KasySidebarItem {
224
+ final IconData icon;
225
+ final String label;
226
+
227
+ /// Leaf tap (navigate). Ignored when [children] is non-empty (a group only
228
+ /// expands/collapses on tap).
229
+ final VoidCallback? onTap;
230
+
231
+ /// Highlights this leaf as the active screen.
232
+ final bool selected;
233
+
234
+ /// Sub-rows. When non-empty this item renders as an expandable group instead
235
+ /// of a leaf.
236
+ final List<KasySidebarSubItem> children;
237
+
238
+ const KasySidebarItem({
239
+ required this.icon,
240
+ required this.label,
241
+ this.onTap,
242
+ this.selected = false,
243
+ this.children = const [],
244
+ });
245
+
246
+ bool get isGroup => children.isNotEmpty;
247
+ }
248
+
249
+ /// A sub-row under an expandable [KasySidebarItem] group (text-only, like the
250
+ /// connected "Income" submenu). [selected] highlights the active screen.
251
+ class KasySidebarSubItem {
252
+ final String label;
253
+ final VoidCallback onTap;
254
+ final bool selected;
255
+ const KasySidebarSubItem({
256
+ required this.label,
257
+ required this.onTap,
258
+ this.selected = false,
259
+ });
260
+ }
261
+
211
262
  /// A SaaS-style sidebar modelled on the HeroUI Figma kit: brand logo + panel
212
263
  /// toggle, a workspace selector, a segmented control, a navigable list with an
213
264
  /// active pill, and a pinned ⌘K search row. Collapses to an icon rail (with
214
265
  /// tooltips and a hover submenu popup) on narrow viewports or via the toggle.
215
266
  ///
267
+ /// Three modes, in priority order:
268
+ /// 1. **Generic** — pass [items] (+ [sectionLabel], [footerItems]) to drive the
269
+ /// rail with an arbitrary screen list (leaves and/or expandable groups). Used
270
+ /// by the admin console so it shares this exact component (logo, collapse,
271
+ /// tooltips, profile) instead of a bespoke copy.
272
+ /// 2. **Connected** — pass [routes]/[onTapItem]/[currentItem] for the real app
273
+ /// tabs (Bart navigation).
274
+ /// 3. **Showcase** — the HeroUI demo items (default).
275
+ ///
216
276
  /// Pass [onSettingsTap] to respond when the user taps Settings.
217
277
  class KasySidebar extends StatefulWidget {
218
278
  const KasySidebar({
@@ -232,8 +292,23 @@ class KasySidebar extends StatefulWidget {
232
292
  this.profileGradient = KasyAvatarGradients.indigo,
233
293
  this.onProfileTap,
234
294
  this.notificationsUnread = 0,
295
+ this.items,
296
+ this.sectionLabel,
297
+ this.footerItems = const [],
235
298
  });
236
299
 
300
+ /// Generic nav rows. When non-null the sidebar runs in generic mode (renders
301
+ /// these — leaves highlighted by their own [KasySidebarItem.selected], groups
302
+ /// expandable) instead of connected/showcase.
303
+ final List<KasySidebarItem>? items;
304
+
305
+ /// Optional uppercase section label shown above [items] (e.g. "ADMIN").
306
+ final String? sectionLabel;
307
+
308
+ /// Rows pinned at the bottom of [items] mode (above the profile block), like
309
+ /// the connected layout's Help/Logout — e.g. a "Back to app" action.
310
+ final List<KasySidebarItem> footerItems;
311
+
237
312
  /// Unread notification count. When greater than zero, the Notifications nav
238
313
  /// item shows an unread dot (mirrors the bottom-bar badge). Purely an unread
239
314
  /// indicator — not tied to push (which is native-only).
@@ -305,6 +380,11 @@ class _KasySidebarState extends State<KasySidebar> {
305
380
 
306
381
  bool _incomeExpanded = false;
307
382
 
383
+ // Expanded groups in generic [items] mode, keyed by the group's label (labels
384
+ // are unique per rail). A group is also shown expanded whenever one of its
385
+ // children is the active screen (so the open submenu always reflects the URL).
386
+ final Set<String> _expandedItemGroups = <String>{};
387
+
308
388
  // Showcase state.
309
389
  int _showcaseTab = 0; // 0 = Layers, 1 = Assets
310
390
  late String _activeItemId;
@@ -399,29 +479,19 @@ class _KasySidebarState extends State<KasySidebar> {
399
479
  ? Border(right: BorderSide(color: c.border, width: 0.5))
400
480
  : Border(left: BorderSide(color: c.border, width: 0.5));
401
481
 
402
- // Figma `shadow-surface`: a soft lift in light mode only (dark uses none).
403
- final List<BoxShadow> shadow = c.isDark
404
- ? const <BoxShadow>[]
405
- : const <BoxShadow>[
406
- BoxShadow(
407
- color: Color(0x14000000),
408
- blurRadius: 4,
409
- offset: Offset(0, 2),
410
- ),
411
- BoxShadow(
412
- color: Color(0x0F000000),
413
- blurRadius: 2,
414
- offset: Offset(0, 1),
415
- ),
416
- ];
417
-
418
- final Widget content = _connected
419
- ? ValueListenableBuilder<int>(
420
- valueListenable: widget.currentItem!,
421
- builder: (_, currentIndex, _) =>
422
- _buildConnectedContent(context, c, currentIndex),
423
- )
424
- : _buildShowcaseContent(context, c);
482
+ // No drop shadow: the rail separates from the content with the crisp 0.5px
483
+ // edge hairline only (it also bled at the seam when the content area didn't
484
+ // overpaint it). The hairline aligns with the web header's bottom border, so
485
+ // the chrome reads as one clean line — no soft contour around the rail.
486
+ final Widget content = widget.items != null
487
+ ? _buildItemsContent(context, c)
488
+ : _connected
489
+ ? ValueListenableBuilder<int>(
490
+ valueListenable: widget.currentItem!,
491
+ builder: (_, currentIndex, _) =>
492
+ _buildConnectedContent(context, c, currentIndex),
493
+ )
494
+ : _buildShowcaseContent(context, c);
425
495
 
426
496
  return Material(
427
497
  type: MaterialType.transparency,
@@ -429,7 +499,7 @@ class _KasySidebarState extends State<KasySidebar> {
429
499
  duration: const Duration(milliseconds: 220),
430
500
  curve: Curves.easeInOut,
431
501
  width: _collapsed ? _kWidthCollapsed : _kWidthOpen,
432
- decoration: BoxDecoration(color: c.bg, boxShadow: shadow),
502
+ decoration: BoxDecoration(color: c.bg),
433
503
  foregroundDecoration: BoxDecoration(border: edgeBorder),
434
504
  clipBehavior: Clip.hardEdge,
435
505
  child: content,
@@ -503,6 +573,305 @@ class _KasySidebarState extends State<KasySidebar> {
503
573
  );
504
574
  }
505
575
 
576
+ // ── Generic layout (host-provided items, e.g. admin console) ────────────────
577
+
578
+ /// Same chrome as the connected layout (logo band, dividers, profile block,
579
+ /// collapse + tooltips) but driven by [KasySidebar.items]/[footerItems], so a
580
+ /// host reuses this exact component with its own screens.
581
+ Widget _buildItemsContent(BuildContext context, _SidebarColors c) {
582
+ final List<KasySidebarItem> items = widget.items!;
583
+ return SizedBox.expand(
584
+ child: Column(
585
+ crossAxisAlignment: CrossAxisAlignment.stretch,
586
+ children: [
587
+ _buildTopBand(c),
588
+ _buildDivider(c),
589
+ Expanded(
590
+ child: Padding(
591
+ padding: EdgeInsets.symmetric(horizontal: _railPadH),
592
+ child: SingleChildScrollView(
593
+ child: Column(
594
+ crossAxisAlignment: CrossAxisAlignment.start,
595
+ children: [
596
+ // Top gap inside the scroll view so the list scrolls flush
597
+ // under the top divider (symmetric with the bottom).
598
+ const SizedBox(height: _kDividerGap),
599
+ if (!_collapsed && widget.sectionLabel != null) ...[
600
+ _buildSectionLabel(widget.sectionLabel!, c),
601
+ const SizedBox(height: _kItemGap),
602
+ ],
603
+ for (final item in items)
604
+ if (item.isGroup)
605
+ _buildItemsGroup(context, item, c)
606
+ else
607
+ _buildItemRow(
608
+ c,
609
+ icon: item.icon,
610
+ label: item.label,
611
+ isActive: item.selected,
612
+ onTap: () => _selectItemsLeaf(item.onTap),
613
+ ),
614
+ ],
615
+ ),
616
+ ),
617
+ ),
618
+ ),
619
+ _buildDivider(c),
620
+ const SizedBox(height: _kFooterGap),
621
+ Padding(
622
+ padding: EdgeInsets.fromLTRB(_railPadH, 0, _railPadH, _kPadBottom),
623
+ child: Column(
624
+ crossAxisAlignment: CrossAxisAlignment.start,
625
+ children: [
626
+ for (final f in widget.footerItems)
627
+ _buildItemRow(
628
+ c,
629
+ icon: f.icon,
630
+ label: f.label,
631
+ isActive: false,
632
+ onTap: () => _selectItemsLeaf(f.onTap),
633
+ ),
634
+ if (widget.showProfile) _buildProfile(c),
635
+ ],
636
+ ),
637
+ ),
638
+ ],
639
+ ),
640
+ );
641
+ }
642
+
643
+ // ── Generic expandable group (items mode) ────────────────────────────────
644
+
645
+ /// Navigating to a top-level (or footer) row collapses any open submenu group
646
+ /// — matching the connected Income dropdown, which closes when you move to
647
+ /// another screen. A group that holds the active screen re-opens on its own
648
+ /// (via [KasySidebarSubItem.selected]), so this only closes a manually-opened
649
+ /// one you're navigating away from.
650
+ void _selectItemsLeaf(VoidCallback? onTap) {
651
+ if (_expandedItemGroups.isNotEmpty) {
652
+ setState(() => _expandedItemGroups.clear());
653
+ }
654
+ onTap?.call();
655
+ }
656
+
657
+ /// An expandable group row in [items] mode — the same dropdown recipe as the
658
+ /// connected "Income" submenu, but driven by [KasySidebarItem.children].
659
+ /// Shown expanded when the user toggled it open OR when one of its children
660
+ /// is the active screen (so the open submenu always reflects the URL).
661
+ Widget _buildItemsGroup(
662
+ BuildContext context,
663
+ KasySidebarItem item,
664
+ _SidebarColors c,
665
+ ) {
666
+ final bool hasActiveChild = item.children.any((s) => s.selected);
667
+ final bool expanded =
668
+ _expandedItemGroups.contains(item.label) || hasActiveChild;
669
+ final Color iconColor = expanded ? c.textActive : c.textMuted;
670
+
671
+ // Collapsed icon rail: the children live in a hover popup (same as Income).
672
+ if (_collapsed) {
673
+ String activeLabel = '';
674
+ for (final s in item.children) {
675
+ if (s.selected) {
676
+ activeLabel = s.label;
677
+ break;
678
+ }
679
+ }
680
+ return Padding(
681
+ padding: const EdgeInsets.only(bottom: _kItemGap),
682
+ child: _ProHoverPopupIcon(
683
+ icon: item.icon,
684
+ iconBg: hasActiveChild ? c.activeBg : Colors.transparent,
685
+ iconColor: iconColor,
686
+ subItems: [for (final s in item.children) s.label],
687
+ activeSubItem: activeLabel,
688
+ colors: c,
689
+ anchoredLeft: widget.side == KasySidebarSide.left,
690
+ onSubItemTap: (label) {
691
+ for (final s in item.children) {
692
+ if (s.label == label) {
693
+ s.onTap();
694
+ break;
695
+ }
696
+ }
697
+ },
698
+ ),
699
+ );
700
+ }
701
+
702
+ return Column(
703
+ crossAxisAlignment: CrossAxisAlignment.start,
704
+ children: [
705
+ KasyHover(
706
+ borderRadius: BorderRadius.circular(_kItemRadius),
707
+ hoverColor: c.activeBg,
708
+ pressColor: c.textActive,
709
+ focusable: true,
710
+ focusGapColor: c.bg,
711
+ onTap: () => setState(() {
712
+ if (!_expandedItemGroups.remove(item.label)) {
713
+ _expandedItemGroups.add(item.label);
714
+ }
715
+ }),
716
+ child: Container(
717
+ constraints: const BoxConstraints(minHeight: _kItemMinH),
718
+ padding: const EdgeInsets.symmetric(
719
+ horizontal: _kItemHPad,
720
+ vertical: _kItemVPad,
721
+ ),
722
+ decoration: BoxDecoration(
723
+ color: hasActiveChild ? c.activeBg : Colors.transparent,
724
+ borderRadius: BorderRadius.circular(_kItemRadius),
725
+ ),
726
+ child: Row(
727
+ children: [
728
+ Icon(item.icon, size: _kIconSize, color: iconColor),
729
+ const SizedBox(width: _kIconGap),
730
+ Expanded(
731
+ child: Text(
732
+ item.label,
733
+ maxLines: 1,
734
+ overflow: TextOverflow.ellipsis,
735
+ style: context.kasyTextTheme.rowTitle.copyWith(
736
+ color: c.textActive,
737
+ ),
738
+ ),
739
+ ),
740
+ AnimatedRotation(
741
+ turns: expanded ? 0.5 : 0,
742
+ duration: const Duration(milliseconds: 200),
743
+ child: Icon(
744
+ KasyIcons.chevronDown,
745
+ size: _kIconSize,
746
+ color: iconColor,
747
+ ),
748
+ ),
749
+ ],
750
+ ),
751
+ ),
752
+ ),
753
+ AnimatedCrossFade(
754
+ duration: const Duration(milliseconds: 200),
755
+ crossFadeState:
756
+ expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond,
757
+ firstChild: _buildItemsSubTree(item.children, c),
758
+ secondChild: const SizedBox.shrink(),
759
+ ),
760
+ const SizedBox(height: _kItemGap),
761
+ ],
762
+ );
763
+ }
764
+
765
+ /// The connector tree under an expanded [items]-mode group (mirrors the
766
+ /// connected Income tree, but generic over [KasySidebarSubItem]).
767
+ Widget _buildItemsSubTree(
768
+ List<KasySidebarSubItem> subItems,
769
+ _SidebarColors c,
770
+ ) {
771
+ final double lineH = _treeLineHeight(subItems.length);
772
+ return Padding(
773
+ padding: const EdgeInsets.only(left: _kSubIndent),
774
+ child: SizedBox(
775
+ width: 172,
776
+ child: Stack(
777
+ clipBehavior: Clip.none,
778
+ children: [
779
+ Positioned(
780
+ left: -_kTreeConnectorW,
781
+ top: 0,
782
+ child: Container(
783
+ width: 1.5,
784
+ height: lineH,
785
+ decoration: BoxDecoration(
786
+ color: c.divider,
787
+ borderRadius: BorderRadius.circular(2),
788
+ ),
789
+ ),
790
+ ),
791
+ Column(
792
+ children: [
793
+ for (int i = 0; i < subItems.length; i++)
794
+ _buildItemsSubItem(subItems[i], i == subItems.length - 1, c),
795
+ ],
796
+ ),
797
+ ],
798
+ ),
799
+ ),
800
+ );
801
+ }
802
+
803
+ Widget _buildItemsSubItem(
804
+ KasySidebarSubItem sub,
805
+ bool isLast,
806
+ _SidebarColors c,
807
+ ) {
808
+ final bool isActive = sub.selected;
809
+ final Color textColor = isActive ? c.textActive : c.textMuted;
810
+
811
+ return Padding(
812
+ padding: EdgeInsets.only(bottom: isLast ? 0 : _kSubItemGap),
813
+ child: Stack(
814
+ clipBehavior: Clip.none,
815
+ children: [
816
+ Positioned(
817
+ left: -_kTreeConnectorW,
818
+ top: _kSubItemH / 2 - 4,
819
+ child: Container(
820
+ width: _kTreeConnectorW,
821
+ height: 8,
822
+ decoration: BoxDecoration(
823
+ border: Border(
824
+ left: BorderSide(color: c.divider, width: 1.5),
825
+ bottom: BorderSide(color: c.divider, width: 1.5),
826
+ ),
827
+ borderRadius: const BorderRadius.only(
828
+ bottomLeft: Radius.circular(8),
829
+ ),
830
+ ),
831
+ ),
832
+ ),
833
+ KasyHover(
834
+ borderRadius: BorderRadius.circular(_kItemRadius),
835
+ hoverColor: c.activeBg,
836
+ pressColor: c.textActive,
837
+ focusable: true,
838
+ focusGapColor: c.bg,
839
+ onTap: sub.onTap,
840
+ child: Container(
841
+ height: _kSubItemH,
842
+ padding: const EdgeInsets.symmetric(
843
+ horizontal: _kItemHPad,
844
+ vertical: 8,
845
+ ),
846
+ // Active sub-item is shown by its LABEL only (bold + active color),
847
+ // never a filled pill — exactly like the connected Income submenu
848
+ // on Home. The pill is reserved for top-level screens.
849
+ decoration: BoxDecoration(
850
+ color: Colors.transparent,
851
+ borderRadius: BorderRadius.circular(_kItemRadius),
852
+ ),
853
+ child: Align(
854
+ alignment: Alignment.centerLeft,
855
+ child: Text(
856
+ sub.label,
857
+ maxLines: 1,
858
+ overflow: TextOverflow.ellipsis,
859
+ // Active sub-item: heavier weight (w700) for a clear "you are
860
+ // here", no pill / accent — emphasis comes from the bold name.
861
+ style: context.textTheme.labelMedium?.copyWith(
862
+ fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
863
+ color: textColor,
864
+ letterSpacing: -0.24,
865
+ ),
866
+ ),
867
+ ),
868
+ ),
869
+ ),
870
+ ],
871
+ ),
872
+ );
873
+ }
874
+
506
875
  // ── Connected layout (real navigation) ──────────────────────────────────────
507
876
 
508
877
  Widget _buildConnectedContent(
@@ -40,12 +40,19 @@ class KasyWebHeader extends StatelessWidget {
40
40
  /// Called when the search field is submitted (Enter).
41
41
  final ValueChanged<String>? onSearchSubmitted;
42
42
 
43
- /// Notifications (bell) action. When null the bell is disabled.
43
+ /// Notifications (bell) action. When null the bell is disabled. Ignored when
44
+ /// [notifications] is provided.
44
45
  final VoidCallback? onNotifications;
45
46
 
46
- /// Shows the unread dot on the notifications bell.
47
+ /// Shows the unread dot on the notifications bell. Ignored when
48
+ /// [notifications] is provided.
47
49
  final bool showNotificationBadge;
48
50
 
51
+ /// Custom notifications control. When set, it replaces the built-in bell —
52
+ /// pass a data-aware widget (e.g. a bell that opens a recent-notifications
53
+ /// dropdown) so the header itself stays a pure presentational component.
54
+ final Widget? notifications;
55
+
49
56
  /// Primary quick-create action. When null the button is disabled.
50
57
  final VoidCallback? onCreate;
51
58
 
@@ -79,6 +86,7 @@ class KasyWebHeader extends StatelessWidget {
79
86
  this.onSearchSubmitted,
80
87
  this.onNotifications,
81
88
  this.showNotificationBadge = false,
89
+ this.notifications,
82
90
  this.onCreate,
83
91
  this.createLabel = 'Create',
84
92
  this.avatarGradient = KasyAvatarGradients.orange,
@@ -120,7 +128,7 @@ class KasyWebHeader extends StatelessWidget {
120
128
  _buildThemeToggle(context),
121
129
  const SizedBox(width: KasySpacing.md),
122
130
  ],
123
- _buildNotifications(context),
131
+ notifications ?? _buildNotifications(context),
124
132
  const SizedBox(width: KasySpacing.md),
125
133
  KasyButton(
126
134
  label: createLabel,