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.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -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/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 +2 -2
- package/templates/firebase/DESIGN_SYSTEM.md +23 -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 +5 -2
- package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
- 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 +27 -18
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
- 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 +11 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
- 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 +28 -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/web_device_preview/web_device_preview.dart +51 -19
- package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- 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 +253 -125
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
- 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 +111 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -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 +2 -2
- 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 +53 -32
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
- 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 +171 -41
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
- 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 +48 -47
- 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 +753 -712
- package/templates/firebase/lib/i18n/es.i18n.json +753 -712
- package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +32 -26
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
- package/templates/firebase/test/app_bar_config_test.dart +70 -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/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 -218
- package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
- 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 -179
- 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
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
),
|