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
@@ -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
+ }