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.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -0
- package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/backends/patch-base-hashes.json +6 -6
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/shared/generator-utils.js +12 -6
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
- package/templates/firebase/AGENTS.md +7 -1
- package/templates/firebase/DESIGN_SYSTEM.md +35 -8
- package/templates/firebase/assets/icons/apple_black.svg +3 -0
- package/templates/firebase/assets/icons/apple_white.svg +4 -0
- package/templates/firebase/assets/icons/facebook.svg +49 -0
- package/templates/firebase/assets/icons/google.svg +1 -0
- package/templates/firebase/functions/src/admin/functions.ts +2 -0
- package/templates/firebase/functions/src/authentication/functions.ts +13 -7
- package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
- package/templates/firebase/lib/components/components.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
- package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
- package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
- package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
- package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
- package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
- package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
- package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
- package/templates/firebase/lib/core/states/logout_action.dart +11 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
- package/templates/firebase/lib/core/theme/texts.dart +21 -6
- package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
- package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
- package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
- package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
- package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
- package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
- package/templates/firebase/lib/features/home/home_feed.dart +2 -2
- package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
- package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
- package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
- package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
- package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
- package/templates/firebase/lib/i18n/en.i18n.json +749 -698
- package/templates/firebase/lib/i18n/es.i18n.json +749 -698
- package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +70 -46
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
- package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
- package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
- package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
- package/templates/firebase/tool/design_check.dart +9 -0
- package/templates/firebase/assets/icons/apple.png +0 -0
- package/templates/firebase/assets/icons/facebook.png +0 -0
- package/templates/firebase/assets/icons/google.png +0 -0
- package/templates/firebase/assets/icons/google_play_games.png +0 -0
- package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
- package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
- package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
- package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
- 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
|
-
|
|
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
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
);
|