kasy-cli 1.37.1 → 1.39.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 (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. 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(
@@ -613,10 +982,18 @@ class _KasySidebarState extends State<KasySidebar> {
613
982
  // border: the web header (68) on desktop, but the shorter KasyAppBar on
614
983
  // tablet (medium), where the page keeps its own app bar instead of the
615
984
  // header. Without this the line breaks between the rail and the app bar.
616
- final double bandHeight =
617
- MediaQuery.sizeOf(context).width >= _kBreakpoint
618
- ? _kTopBandHeight
619
- : kasyAppBarBodyTopOverlap(context);
985
+ final bool isCompact = MediaQuery.sizeOf(context).width < _kBreakpoint;
986
+ // The drawer trims a little off the band so there's less dead space below the
987
+ // wordmark before the divider (the drawer is an overlay, so its divider has
988
+ // no app-bar line to stay aligned with).
989
+ final double bandHeight = !isCompact
990
+ ? _kTopBandHeight
991
+ : kasyAppBarBodyTopOverlap(context) - (widget.isDrawer ? 26.0 : 0.0);
992
+ // In the mobile drawer the band starts right under the status bar notch, so
993
+ // nudge the wordmark down a touch to breathe. Restricted to the drawer (an
994
+ // overlay) so the inline rail keeps its divider aligned with the app bar /
995
+ // web header on tablet and desktop.
996
+ final double logoTopInset = widget.isDrawer ? 20.0 : 0.0;
620
997
  // The collapse toggle is available on every breakpoint so any config can be
621
998
  // switched thin↔wide — except a drawer, which is a dismissible overlay you
622
999
  // close whole rather than collapse in place.
@@ -627,7 +1004,7 @@ class _KasySidebarState extends State<KasySidebar> {
627
1004
  c.isDark
628
1005
  ? 'assets/images/logo_wordmark_dark.png'
629
1006
  : 'assets/images/logo_wordmark_light.png',
630
- height: 32,
1007
+ height: widget.isDrawer ? 44 : 38,
631
1008
  fit: BoxFit.contain,
632
1009
  );
633
1010
  // Left rail: wordmark then toggle (toggle hugs the content edge). The right
@@ -637,7 +1014,11 @@ class _KasySidebarState extends State<KasySidebar> {
637
1014
  if (showToggle) ...[const Spacer(), _buildToggleButton(c)],
638
1015
  ];
639
1016
  return Padding(
640
- padding: EdgeInsets.symmetric(horizontal: _railPadH),
1017
+ padding: EdgeInsets.only(
1018
+ left: _railPadH,
1019
+ right: _railPadH,
1020
+ top: logoTopInset,
1021
+ ),
641
1022
  child: SizedBox(
642
1023
  height: bandHeight,
643
1024
  child: _collapsed
@@ -519,6 +519,12 @@ class _PrimaryTabState extends State<_PrimaryTab> {
519
519
  child: Text(
520
520
  item.label,
521
521
  textAlign: TextAlign.center,
522
+ // Fill mode gives each tab an equal share of the width; a long label
523
+ // (often a longer localized string) ellipsizes within its slot instead
524
+ // of overflowing the row. Hug mode is intrinsic + scrollable, so the
525
+ // single line never clips there.
526
+ maxLines: 1,
527
+ overflow: TextOverflow.ellipsis,
522
528
  // Use labelLarge as defined in the Kasy theme (14px/w600).
523
529
  // No fontWeight override — the theme token is the source of truth.
524
530
  style: context.textTheme.labelLarge?.copyWith(
@@ -559,7 +565,13 @@ class _PrimaryTabState extends State<_PrimaryTab> {
559
565
  if (item.icon != null) iconWidget,
560
566
  if (item.icon != null && hasLabel)
561
567
  const SizedBox(width: 6),
562
- if (hasLabel) labelWidget,
568
+ // Constrain the label only in fill mode (bounded width via
569
+ // Expanded). Hug mode lays out in an unbounded scroll view,
570
+ // where a Flexible child would have no bound to flex within.
571
+ if (hasLabel)
572
+ widget.expand
573
+ ? Flexible(child: labelWidget)
574
+ : labelWidget,
563
575
  ],
564
576
  ),
565
577
  ),
@@ -657,16 +669,25 @@ class _SecondaryTabState extends State<_SecondaryTab> {
657
669
  if (hasLabel) const SizedBox(width: 6),
658
670
  ],
659
671
  if (hasLabel)
660
- Opacity(
661
- opacity: disabled ? 0.4 : 1.0,
662
- child: Text(
663
- item.label,
664
- // Use labelLarge as defined in the Kasy theme (14px/w600).
665
- style: context.textTheme.labelLarge?.copyWith(
666
- color: fg,
672
+ // Fill mode constrains the label to its equal slot (ellipsis on
673
+ // overflow); hug mode stays intrinsic + scrollable. See the
674
+ // primary variant for the unbounded-width rationale.
675
+ Builder(builder: (context) {
676
+ final Widget label = Opacity(
677
+ opacity: disabled ? 0.4 : 1.0,
678
+ child: Text(
679
+ item.label,
680
+ textAlign: TextAlign.center,
681
+ maxLines: 1,
682
+ overflow: TextOverflow.ellipsis,
683
+ // Use labelLarge as defined in the Kasy theme (14px/w600).
684
+ style: context.textTheme.labelLarge?.copyWith(
685
+ color: fg,
686
+ ),
667
687
  ),
668
- ),
669
- ),
688
+ );
689
+ return widget.expand ? Flexible(child: label) : label;
690
+ }),
670
691
  ],
671
692
  ),
672
693
  ),
@@ -29,7 +29,16 @@ class KasyTextField extends StatefulWidget {
29
29
  /// changing this value grows or shrinks the visible box on every platform.
30
30
  /// Matches the medium [KasyButton] height (45) so fields, the DatePicker
31
31
  /// trigger and the primary action all share one control height.
32
- static const double singleLineHeight = 41;
32
+ static const double singleLineHeight = 45;
33
+
34
+ /// The single-line field renders its text at a fixed size and a *forced* line
35
+ /// box (see the [StrutStyle] on the inner field) so the box height is the same
36
+ /// on every renderer and breakpoint. Flutter web (CanvasKit) and native
37
+ /// otherwise disagree on a font's intrinsic line metrics by ~1px, and since
38
+ /// the box height is `text line + padding`, that variance would leak into the
39
+ /// visible height. Forcing the line to [fieldLineHeight] removes it entirely.
40
+ static const double fieldFontSize = 15;
41
+ static const double fieldLineHeight = 23;
33
42
 
34
43
  final TextEditingController? controller;
35
44
  final FocusNode? focusNode;
@@ -301,7 +310,10 @@ class _KasyTextFieldState extends State<KasyTextField> {
301
310
  // padding. So to hit [singleLineHeight] we back out the padding from it
302
311
  // (subtract one text line, halve). This is the only lever that actually
303
312
  // stretches the visible box — constraints/SizedBox just pad around it.
304
- const double singleLineTextHeight = 19;
313
+ // The text line is forced to exactly [fieldLineHeight] (see the strut on the
314
+ // inner field), so this is deterministic on every renderer/platform:
315
+ // box = fieldLineHeight + 2×padding = singleLineHeight, always (45 → 11px).
316
+ const double singleLineTextHeight = KasyTextField.fieldLineHeight;
305
317
  final double singleLineVerticalPadding =
306
318
  ((KasyTextField.singleLineHeight - singleLineTextHeight) / 2)
307
319
  .clamp(0.0, 60.0);
@@ -487,11 +499,14 @@ class _KasyTextFieldState extends State<KasyTextField> {
487
499
 
488
500
  final Color fieldTextColor = dimDisabled(context.colors.onSurface);
489
501
  final TextStyle fieldTextStyle =
490
- context.textTheme.bodyLarge?.copyWith(
491
- color: fieldTextColor,
492
- fontSize: 15,
493
- ) ??
494
- TextStyle(fontSize: 15, color: fieldTextColor);
502
+ (context.textTheme.bodyLarge ?? const TextStyle()).copyWith(
503
+ color: fieldTextColor,
504
+ fontSize: KasyTextField.fieldFontSize,
505
+ // Pin the line-height so the text box is a fixed [fieldLineHeight]px;
506
+ // paired with the forced strut on the field below, this makes the control
507
+ // height identical on every renderer (web CanvasKit vs native) and size.
508
+ height: KasyTextField.fieldLineHeight / KasyTextField.fieldFontSize,
509
+ );
495
510
  final InputDecoration decoration = InputDecoration(
496
511
  isDense: true,
497
512
  hintText: widget.hint,
@@ -583,6 +598,13 @@ class _KasyTextFieldState extends State<KasyTextField> {
583
598
  maxLength: widget.maxLength,
584
599
  buildCounter: hasCounter ? _hideInputCounter : null,
585
600
  style: fieldTextStyle,
601
+ // Force the line box to exactly [fieldLineHeight] regardless of the font's
602
+ // intrinsic metrics, so the field is the same height on every renderer
603
+ // (web CanvasKit and native disagree by ~1px otherwise) and breakpoint.
604
+ strutStyle: StrutStyle.fromTextStyle(
605
+ fieldTextStyle,
606
+ forceStrutHeight: true,
607
+ ),
586
608
  decoration: decoration,
587
609
  enableInteractiveSelection: widget.enableInteractiveSelection,
588
610
  );