kasy-cli 1.38.0 → 1.39.1

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 (105) 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/features/notifications/api/device_api.dart +11 -6
  4. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  5. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  6. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  7. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  9. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  11. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  12. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  13. package/lib/scaffold/shared/generator-utils.js +12 -6
  14. package/package.json +1 -1
  15. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  16. package/templates/firebase/AGENTS.md +2 -2
  17. package/templates/firebase/DESIGN_SYSTEM.md +23 -8
  18. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  19. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  20. package/templates/firebase/assets/icons/facebook.svg +49 -0
  21. package/templates/firebase/assets/icons/google.svg +1 -0
  22. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  23. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  24. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  25. package/templates/firebase/lib/components/components.dart +5 -2
  26. package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
  27. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  28. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  29. package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
  30. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  31. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  32. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +27 -18
  33. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
  34. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  35. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  36. package/templates/firebase/lib/core/data/api/user_api.dart +11 -0
  37. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  38. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
  39. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  40. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  41. package/templates/firebase/lib/core/states/user_state_notifier.dart +28 -1
  42. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  43. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  44. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +51 -19
  45. package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
  46. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  47. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  48. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  49. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  50. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  51. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  52. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  53. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  54. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  55. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  56. package/templates/firebase/lib/features/home/home_components_page.dart +253 -125
  57. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
  58. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  59. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  60. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  61. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +111 -57
  62. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  63. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -4
  64. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  65. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  66. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  67. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +2 -2
  68. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  69. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  70. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  72. package/templates/firebase/lib/features/settings/settings_page.dart +53 -32
  73. package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
  74. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
  75. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  76. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  77. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +171 -41
  78. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
  79. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  80. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +48 -47
  81. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  82. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  83. package/templates/firebase/lib/i18n/en.i18n.json +753 -712
  84. package/templates/firebase/lib/i18n/es.i18n.json +753 -712
  85. package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
  86. package/templates/firebase/lib/main.dart +20 -7
  87. package/templates/firebase/lib/router.dart +32 -26
  88. package/templates/firebase/pubspec.yaml +2 -1
  89. package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
  90. package/templates/firebase/test/app_bar_config_test.dart +70 -0
  91. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  92. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  93. package/templates/firebase/tool/design_check.dart +9 -0
  94. package/templates/firebase/assets/icons/apple.png +0 -0
  95. package/templates/firebase/assets/icons/facebook.png +0 -0
  96. package/templates/firebase/assets/icons/google.png +0 -0
  97. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  98. package/templates/firebase/lib/components/kasy_web_header.dart +0 -218
  99. package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
  100. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  101. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  102. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  103. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  104. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -179
  105. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
@@ -0,0 +1,584 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/components/kasy_text_field.dart';
3
+ import 'package:kasy_kit/core/theme/theme.dart';
4
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
5
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // KasyDropDown — design-system single-select dropdown (HeroUI "Select").
9
+ //
10
+ // Two parts, mirroring the Figma spec:
11
+ // • Trigger — reuses [KasyTextField] in read-only mode, so it inherits the
12
+ // kit input look (label, placeholder, border, shadow, focus
13
+ // affordance). Shows the selected option or a placeholder, plus
14
+ // a chevron that flips while open.
15
+ // • Dropdown — a floating panel anchored to the trigger via [OverlayPortal]
16
+ // (not a Material PopupMenu). The popover lives under the app's
17
+ // Overlay, so it inherits the real light/dark theme — fixing the
18
+ // classic "white menu in dark mode" of raw PopupMenuButton. Opens
19
+ // downward by default, or upward when there isn't room below.
20
+ //
21
+ // Usage:
22
+ // KasyDropDown<String>(
23
+ // label: 'State',
24
+ // hint: 'Select one',
25
+ // showRequiredIndicator: true,
26
+ // value: selected,
27
+ // items: const [
28
+ // KasyDropDownItem(value: 'fl', label: 'Florida'),
29
+ // KasyDropDownItem(value: 'tx', label: 'Texas'),
30
+ // ],
31
+ // onChanged: (v) => setState(() => selected = v),
32
+ // )
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ /// One selectable option in a [KasyDropDown].
36
+ class KasyDropDownItem<T> {
37
+ const KasyDropDownItem({
38
+ required this.value,
39
+ required this.label,
40
+ this.icon,
41
+ this.subtitle,
42
+ this.enabled = true,
43
+ });
44
+
45
+ /// The value reported through [KasyDropDown.onChanged] when this option is
46
+ /// picked. Must be unique within the list.
47
+ final T value;
48
+
49
+ /// Primary text shown for the option (and in the trigger once selected).
50
+ final String label;
51
+
52
+ /// Optional leading icon, rendered before the label.
53
+ final IconData? icon;
54
+
55
+ /// Optional secondary line below the label (muted).
56
+ final String? subtitle;
57
+
58
+ /// When false, the option is dimmed and not selectable.
59
+ final bool enabled;
60
+ }
61
+
62
+ /// Design-system single-select dropdown. See file header for the anatomy.
63
+ class KasyDropDown<T> extends StatefulWidget {
64
+ const KasyDropDown({
65
+ super.key,
66
+ required this.items,
67
+ required this.onChanged,
68
+ this.value,
69
+ this.label,
70
+ this.hint,
71
+ this.showRequiredIndicator = false,
72
+ this.description,
73
+ this.errorText,
74
+ this.isInvalid = false,
75
+ this.enabled = true,
76
+ this.leadingIcon,
77
+ this.variant = KasyTextFieldVariant.primary,
78
+ this.focusBorder = true,
79
+ this.showSelectedCheck = true,
80
+ this.maxPanelHeight = 280,
81
+ this.semanticLabel,
82
+ });
83
+
84
+ /// The options to choose from.
85
+ final List<KasyDropDownItem<T>> items;
86
+
87
+ /// Called with the picked value. Pass null to render a read-only dropdown.
88
+ final ValueChanged<T>? onChanged;
89
+
90
+ /// Currently selected value (matched against [KasyDropDownItem.value]).
91
+ final T? value;
92
+
93
+ /// Label rendered above the trigger.
94
+ final String? label;
95
+
96
+ /// Placeholder shown in the trigger while nothing is selected.
97
+ final String? hint;
98
+
99
+ /// Shows a danger `*` after the [label].
100
+ final bool showRequiredIndicator;
101
+
102
+ /// Helper text rendered below the trigger (hidden when [errorText] is set).
103
+ final String? description;
104
+
105
+ /// Error text rendered below the trigger; also flips the field to invalid.
106
+ final String? errorText;
107
+
108
+ /// Forces the invalid (danger) styling even without [errorText].
109
+ final bool isInvalid;
110
+
111
+ /// When false, the trigger is dimmed and won't open.
112
+ final bool enabled;
113
+
114
+ /// Optional leading icon shown inside the trigger.
115
+ final IconData? leadingIcon;
116
+
117
+ /// Trigger fill style — mirrors [KasyTextField.variant].
118
+ final KasyTextFieldVariant variant;
119
+
120
+ /// Whether the trigger paints the primary "active" border while open.
121
+ final bool focusBorder;
122
+
123
+ /// Whether the selected option shows a trailing check in the panel.
124
+ final bool showSelectedCheck;
125
+
126
+ /// Maximum height of the dropdown panel before it scrolls internally.
127
+ final double maxPanelHeight;
128
+
129
+ /// Accessibility label for the trigger (falls back to [label]/[hint]).
130
+ final String? semanticLabel;
131
+
132
+ @override
133
+ State<KasyDropDown<T>> createState() => _KasyDropDownState<T>();
134
+ }
135
+
136
+ class _KasyDropDownState<T> extends State<KasyDropDown<T>>
137
+ with SingleTickerProviderStateMixin {
138
+ final LayerLink _layerLink = LayerLink();
139
+ final OverlayPortalController _portalController = OverlayPortalController();
140
+
141
+ // Groups the trigger + panel for TapRegion so a tap inside either is "inside".
142
+ final Object _tapGroupId = Object();
143
+
144
+ // Measures the trigger width when opening so the panel matches it.
145
+ final GlobalKey _fieldKey = GlobalKey();
146
+
147
+ // Trigger text mirrors the selected label (empty → hint renders).
148
+ late final TextEditingController _displayController;
149
+
150
+ late final AnimationController _animCtrl;
151
+ late final Animation<double> _fadeAnim;
152
+ late final Animation<double> _scaleAnim;
153
+
154
+ bool _isOpen = false;
155
+
156
+ // Drives the trigger's "active" border while the panel is open — from state,
157
+ // not real focus (see KasyDatePicker for the rationale).
158
+ bool _triggerActive = false;
159
+
160
+ // Trigger width + open direction, read at open time.
161
+ double _panelWidth = 256;
162
+ bool _openUp = false;
163
+
164
+ @override
165
+ void initState() {
166
+ super.initState();
167
+ _displayController = TextEditingController(text: _selectedLabel());
168
+ _animCtrl = AnimationController(
169
+ vsync: this,
170
+ duration: const Duration(milliseconds: 160),
171
+ );
172
+ _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
173
+ _scaleAnim = Tween<double>(begin: 0.96, end: 1.0).animate(
174
+ CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut),
175
+ );
176
+ }
177
+
178
+ @override
179
+ void didUpdateWidget(covariant KasyDropDown<T> oldWidget) {
180
+ super.didUpdateWidget(oldWidget);
181
+ // Keep the trigger text in sync when the selection or items change upstream.
182
+ if (oldWidget.value != widget.value || oldWidget.items != widget.items) {
183
+ _displayController.text = _selectedLabel();
184
+ }
185
+ }
186
+
187
+ @override
188
+ void dispose() {
189
+ _animCtrl.dispose();
190
+ _displayController.dispose();
191
+ super.dispose();
192
+ }
193
+
194
+ KasyDropDownItem<T>? get _selectedItem {
195
+ for (final item in widget.items) {
196
+ if (item.value == widget.value) return item;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ String _selectedLabel() => _selectedItem?.label ?? '';
202
+
203
+ void _toggle() {
204
+ if (!widget.enabled || widget.items.isEmpty) return;
205
+ if (_isOpen) {
206
+ _close();
207
+ } else {
208
+ _open();
209
+ }
210
+ }
211
+
212
+ void _open() {
213
+ final RenderBox? fieldBox =
214
+ _fieldKey.currentContext?.findRenderObject() as RenderBox?;
215
+ _panelWidth = fieldBox?.size.width ?? 256;
216
+
217
+ // Estimate the panel height so we can flip up when there's no room below.
218
+ final double estimatedHeight = _estimatedPanelHeight();
219
+ bool openUp = false;
220
+ if (fieldBox != null) {
221
+ final Offset fieldTopLeft = fieldBox.localToGlobal(Offset.zero);
222
+ final Size viewport = MediaQuery.sizeOf(context);
223
+ final EdgeInsets safe = MediaQuery.viewPaddingOf(context);
224
+ final double fieldBottom = fieldTopLeft.dy + fieldBox.size.height;
225
+ final double spaceBelow = viewport.height - safe.bottom - fieldBottom;
226
+ final double spaceAbove = fieldTopLeft.dy - safe.top;
227
+ if (spaceBelow < estimatedHeight + 8 && spaceAbove > spaceBelow) {
228
+ openUp = true;
229
+ }
230
+ }
231
+ _openUp = openUp;
232
+
233
+ _portalController.show();
234
+ _animCtrl.forward();
235
+ setState(() {
236
+ _isOpen = true;
237
+ _triggerActive = true;
238
+ });
239
+ }
240
+
241
+ void _close() {
242
+ _animCtrl.reverse().then((_) {
243
+ if (!mounted) return;
244
+ _portalController.hide();
245
+ setState(() => _triggerActive = false);
246
+ });
247
+ setState(() => _isOpen = false);
248
+ }
249
+
250
+ void _select(KasyDropDownItem<T> item) {
251
+ if (!item.enabled) return;
252
+ widget.onChanged?.call(item.value);
253
+ _close();
254
+ }
255
+
256
+ // Roughly: item rows (36 each) + panel padding, capped at maxPanelHeight.
257
+ double _estimatedPanelHeight() {
258
+ const double rowHeight = 38;
259
+ const double panelPadding = KasySpacing.xs * 2;
260
+ final double raw = widget.items.length * rowHeight + panelPadding;
261
+ return raw.clamp(0, widget.maxPanelHeight);
262
+ }
263
+
264
+ @override
265
+ Widget build(BuildContext context) {
266
+ return TapRegion(
267
+ groupId: _tapGroupId,
268
+ child: OverlayPortal(
269
+ controller: _portalController,
270
+ overlayChildBuilder: _buildOverlay,
271
+ child: _buildTrigger(context),
272
+ ),
273
+ );
274
+ }
275
+
276
+ // ── Trigger ────────────────────────────────────────────────────────────────
277
+ Widget _buildTrigger(BuildContext context) {
278
+ final KasyColors c = context.colors;
279
+ final bool isDisabled = !widget.enabled;
280
+ final bool hasErrorText =
281
+ widget.errorText != null && widget.errorText!.isNotEmpty;
282
+ final bool hasInvalidState = widget.isInvalid || hasErrorText;
283
+ final String? footerText =
284
+ hasErrorText ? widget.errorText : widget.description;
285
+ final Color labelColor = hasInvalidState ? c.error : c.fieldLabel;
286
+
287
+ Color dimDisabled(Color base, {double alpha = 0.55}) {
288
+ if (!isDisabled) return base;
289
+ return Color.alphaBlend(base.withValues(alpha: alpha), c.surface);
290
+ }
291
+
292
+ return Column(
293
+ crossAxisAlignment: CrossAxisAlignment.start,
294
+ mainAxisSize: MainAxisSize.min,
295
+ children: [
296
+ if (widget.label != null && widget.label!.trim().isNotEmpty) ...[
297
+ Padding(
298
+ padding: const EdgeInsets.only(left: KasySpacing.sm - 2),
299
+ child: Row(
300
+ mainAxisSize: MainAxisSize.min,
301
+ children: [
302
+ Text(
303
+ widget.label!,
304
+ style: context.textTheme.bodyMedium?.copyWith(
305
+ color: dimDisabled(labelColor),
306
+ fontWeight: FontWeight.w500,
307
+ ),
308
+ ),
309
+ if (widget.showRequiredIndicator)
310
+ Text(
311
+ ' *',
312
+ style: context.textTheme.bodyMedium?.copyWith(
313
+ color: c.error,
314
+ fontWeight: FontWeight.w500,
315
+ ),
316
+ ),
317
+ ],
318
+ ),
319
+ ),
320
+ const SizedBox(height: KasySpacing.xs),
321
+ ],
322
+ // Only this rectangle is the popover anchor — label and footer are
323
+ // siblings so they don't shift the panel's open position.
324
+ CompositedTransformTarget(
325
+ link: _layerLink,
326
+ child: MouseRegion(
327
+ cursor: widget.enabled
328
+ ? SystemMouseCursors.click
329
+ : SystemMouseCursors.basic,
330
+ child: KasyFocusRing(
331
+ onActivate: _toggle,
332
+ borderRadius: BorderRadius.circular(KasyRadius.md),
333
+ child: GestureDetector(
334
+ behavior: HitTestBehavior.opaque,
335
+ onTap: _toggle,
336
+ child: IgnorePointer(
337
+ child: KasyTextField(
338
+ key: _fieldKey,
339
+ controller: _displayController,
340
+ readOnly: true,
341
+ enabled: widget.enabled,
342
+ hint: widget.hint,
343
+ isInvalid: hasInvalidState,
344
+ variant: widget.variant,
345
+ focusBorder: widget.focusBorder,
346
+ forceFocusBorder: widget.focusBorder && _triggerActive,
347
+ enableInteractiveSelection: false,
348
+ semanticLabel:
349
+ widget.semanticLabel ?? widget.label ?? widget.hint,
350
+ prefix: widget.leadingIcon == null
351
+ ? null
352
+ : Icon(widget.leadingIcon, size: KasyIconSize.sm),
353
+ suffix: AnimatedRotation(
354
+ turns: _isOpen ? 0.5 : 0.0,
355
+ duration: const Duration(milliseconds: 160),
356
+ child: const Icon(KasyIcons.chevronDown),
357
+ ),
358
+ ),
359
+ ),
360
+ ),
361
+ ),
362
+ ),
363
+ ),
364
+ if (footerText != null && footerText.isNotEmpty) ...[
365
+ const SizedBox(height: KasySpacing.xs),
366
+ Padding(
367
+ padding: const EdgeInsets.symmetric(horizontal: KasySpacing.sm - 2),
368
+ child: Text(
369
+ footerText,
370
+ style: context.textTheme.bodySmall?.copyWith(
371
+ color: hasErrorText ? c.error : c.muted,
372
+ ),
373
+ ),
374
+ ),
375
+ ],
376
+ ],
377
+ );
378
+ }
379
+
380
+ // ── Dropdown panel (popover) ─────────────────────────────────────────────────
381
+ Widget _buildOverlay(BuildContext context) {
382
+ return TapRegion(
383
+ groupId: _tapGroupId,
384
+ onTapOutside: (_) => _close(),
385
+ child: Stack(
386
+ children: [
387
+ CompositedTransformFollower(
388
+ link: _layerLink,
389
+ targetAnchor:
390
+ _openUp ? Alignment.topLeft : Alignment.bottomLeft,
391
+ followerAnchor:
392
+ _openUp ? Alignment.bottomLeft : Alignment.topLeft,
393
+ // 6px gap between trigger and panel.
394
+ offset: _openUp ? const Offset(0, -6) : const Offset(0, 6),
395
+ child: FadeTransition(
396
+ opacity: _fadeAnim,
397
+ child: ScaleTransition(
398
+ scale: _scaleAnim,
399
+ alignment:
400
+ _openUp ? Alignment.bottomCenter : Alignment.topCenter,
401
+ child: _DropDownPanel<T>(
402
+ width: _panelWidth,
403
+ maxHeight: widget.maxPanelHeight,
404
+ items: widget.items,
405
+ selectedValue: widget.value,
406
+ showSelectedCheck: widget.showSelectedCheck,
407
+ onSelected: _select,
408
+ ),
409
+ ),
410
+ ),
411
+ ),
412
+ ],
413
+ ),
414
+ );
415
+ }
416
+ }
417
+
418
+ // ─────────────────────────────────────────────────────────────────────────────
419
+ // _DropDownPanel — the floating list surface
420
+ // ─────────────────────────────────────────────────────────────────────────────
421
+
422
+ class _DropDownPanel<T> extends StatelessWidget {
423
+ const _DropDownPanel({
424
+ required this.width,
425
+ required this.maxHeight,
426
+ required this.items,
427
+ required this.selectedValue,
428
+ required this.showSelectedCheck,
429
+ required this.onSelected,
430
+ });
431
+
432
+ final double width;
433
+ final double maxHeight;
434
+ final List<KasyDropDownItem<T>> items;
435
+ final T? selectedValue;
436
+ final bool showSelectedCheck;
437
+ final ValueChanged<KasyDropDownItem<T>> onSelected;
438
+
439
+ @override
440
+ Widget build(BuildContext context) {
441
+ final KasyColors c = context.colors;
442
+
443
+ return Material(
444
+ type: MaterialType.transparency,
445
+ child: Container(
446
+ width: width,
447
+ constraints: BoxConstraints(maxHeight: maxHeight),
448
+ padding: const EdgeInsets.all(KasySpacing.xs),
449
+ decoration: BoxDecoration(
450
+ color: c.surface,
451
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
452
+ border: Border.all(color: c.outline.withValues(alpha: 0.5)),
453
+ boxShadow: [
454
+ BoxShadow(
455
+ color: Colors.black.withValues(alpha: context.isDark ? 0.45 : 0.12),
456
+ blurRadius: 28,
457
+ offset: const Offset(0, 14),
458
+ ),
459
+ BoxShadow(
460
+ color: Colors.black.withValues(alpha: context.isDark ? 0.30 : 0.06),
461
+ blurRadius: 8,
462
+ offset: const Offset(0, 2),
463
+ ),
464
+ ],
465
+ ),
466
+ child: ClipRRect(
467
+ borderRadius: BorderRadius.circular(KasyRadius.xl - KasySpacing.xs),
468
+ child: SingleChildScrollView(
469
+ child: Column(
470
+ mainAxisSize: MainAxisSize.min,
471
+ children: [
472
+ for (final item in items)
473
+ _DropDownTile<T>(
474
+ item: item,
475
+ selected: item.value == selectedValue,
476
+ showSelectedCheck: showSelectedCheck,
477
+ onTap: () => onSelected(item),
478
+ ),
479
+ ],
480
+ ),
481
+ ),
482
+ ),
483
+ ),
484
+ );
485
+ }
486
+ }
487
+
488
+ // ─────────────────────────────────────────────────────────────────────────────
489
+ // _DropDownTile — a single option row
490
+ // ─────────────────────────────────────────────────────────────────────────────
491
+
492
+ class _DropDownTile<T> extends StatelessWidget {
493
+ const _DropDownTile({
494
+ required this.item,
495
+ required this.selected,
496
+ required this.showSelectedCheck,
497
+ required this.onTap,
498
+ });
499
+
500
+ final KasyDropDownItem<T> item;
501
+ final bool selected;
502
+ final bool showSelectedCheck;
503
+ final VoidCallback onTap;
504
+
505
+ @override
506
+ Widget build(BuildContext context) {
507
+ final KasyColors c = context.colors;
508
+ final bool isDisabled = !item.enabled;
509
+ final Color foreground = selected ? c.primary : c.onSurface;
510
+ final Color resolved = isDisabled
511
+ ? Color.alphaBlend(foreground.withValues(alpha: 0.45), c.surface)
512
+ : foreground;
513
+
514
+ final Widget row = Row(
515
+ children: [
516
+ if (item.icon != null) ...[
517
+ Icon(
518
+ item.icon,
519
+ size: KasyIconSize.sm,
520
+ color: isDisabled
521
+ ? Color.alphaBlend(c.muted.withValues(alpha: 0.5), c.surface)
522
+ : (selected ? c.primary : c.muted),
523
+ ),
524
+ const SizedBox(width: KasySpacing.smd),
525
+ ],
526
+ Expanded(
527
+ child: Column(
528
+ crossAxisAlignment: CrossAxisAlignment.start,
529
+ mainAxisSize: MainAxisSize.min,
530
+ children: [
531
+ Text(
532
+ item.label,
533
+ maxLines: 1,
534
+ overflow: TextOverflow.ellipsis,
535
+ style: context.textTheme.bodyMedium?.copyWith(
536
+ color: resolved,
537
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
538
+ ),
539
+ ),
540
+ if (item.subtitle != null && item.subtitle!.isNotEmpty)
541
+ Text(
542
+ item.subtitle!,
543
+ maxLines: 1,
544
+ overflow: TextOverflow.ellipsis,
545
+ style: context.textTheme.bodySmall?.copyWith(color: c.muted),
546
+ ),
547
+ ],
548
+ ),
549
+ ),
550
+ if (selected && showSelectedCheck) ...[
551
+ const SizedBox(width: KasySpacing.smd),
552
+ Icon(KasyIcons.check, size: KasyIconSize.sm, color: c.primary),
553
+ ],
554
+ ],
555
+ );
556
+
557
+ if (isDisabled) {
558
+ return Padding(
559
+ padding: const EdgeInsets.symmetric(
560
+ horizontal: KasySpacing.smd,
561
+ vertical: 6,
562
+ ),
563
+ child: row,
564
+ );
565
+ }
566
+
567
+ return KasyHover(
568
+ onTap: onTap,
569
+ focusable: true,
570
+ semanticLabel: item.label,
571
+ borderRadius: BorderRadius.circular(KasyRadius.rounded2_5xl),
572
+ hoverColor: selected ? c.accentSoft : c.surfaceSecondary,
573
+ pressColor: c.primary,
574
+ padding: const EdgeInsets.symmetric(
575
+ horizontal: KasySpacing.smd,
576
+ vertical: 6,
577
+ ),
578
+ child: ConstrainedBox(
579
+ constraints: const BoxConstraints(minHeight: 24),
580
+ child: Align(alignment: Alignment.centerLeft, child: row),
581
+ ),
582
+ );
583
+ }
584
+ }
@@ -982,10 +982,18 @@ class _KasySidebarState extends State<KasySidebar> {
982
982
  // border: the web header (68) on desktop, but the shorter KasyAppBar on
983
983
  // tablet (medium), where the page keeps its own app bar instead of the
984
984
  // header. Without this the line breaks between the rail and the app bar.
985
- final double bandHeight =
986
- MediaQuery.sizeOf(context).width >= _kBreakpoint
987
- ? _kTopBandHeight
988
- : 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;
989
997
  // The collapse toggle is available on every breakpoint so any config can be
990
998
  // switched thin↔wide — except a drawer, which is a dismissible overlay you
991
999
  // close whole rather than collapse in place.
@@ -996,7 +1004,7 @@ class _KasySidebarState extends State<KasySidebar> {
996
1004
  c.isDark
997
1005
  ? 'assets/images/logo_wordmark_dark.png'
998
1006
  : 'assets/images/logo_wordmark_light.png',
999
- height: 32,
1007
+ height: widget.isDrawer ? 44 : 38,
1000
1008
  fit: BoxFit.contain,
1001
1009
  );
1002
1010
  // Left rail: wordmark then toggle (toggle hugs the content edge). The right
@@ -1006,7 +1014,11 @@ class _KasySidebarState extends State<KasySidebar> {
1006
1014
  if (showToggle) ...[const Spacer(), _buildToggleButton(c)],
1007
1015
  ];
1008
1016
  return Padding(
1009
- padding: EdgeInsets.symmetric(horizontal: _railPadH),
1017
+ padding: EdgeInsets.only(
1018
+ left: _railPadH,
1019
+ right: _railPadH,
1020
+ top: logoTopInset,
1021
+ ),
1010
1022
  child: SizedBox(
1011
1023
  height: bandHeight,
1012
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
  ),