kasy-cli 1.16.0 → 1.18.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 (99) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +52 -19
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +54 -6
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +404 -213
  9. package/lib/commands/remove.js +14 -3
  10. package/lib/commands/run.js +208 -6
  11. package/lib/commands/splash.js +5 -5
  12. package/lib/commands/update.js +9 -9
  13. package/lib/scaffold/CHANGELOG.json +23 -0
  14. package/lib/scaffold/backends/api/patch/README.md +3 -2
  15. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +44 -5
  17. package/lib/scaffold/backends/supabase/patch/README.md +3 -2
  18. package/lib/scaffold/generate.js +24 -8
  19. package/lib/scaffold/shared/generator-utils.js +52 -8
  20. package/lib/scaffold/shared/post-build.js +113 -31
  21. package/lib/scaffold/shared/template-strings.js +6 -0
  22. package/lib/utils/brand.js +16 -12
  23. package/lib/utils/flutter-run.js +139 -11
  24. package/lib/utils/i18n/messages-en.js +85 -7
  25. package/lib/utils/i18n/messages-es.js +85 -7
  26. package/lib/utils/i18n/messages-pt.js +86 -8
  27. package/lib/utils/ui.js +79 -4
  28. package/package.json +1 -1
  29. package/templates/firebase/README.en.md +18 -8
  30. package/templates/firebase/README.es.md +18 -8
  31. package/templates/firebase/README.md +18 -8
  32. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +68 -45
  33. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidgetReceiver.kt +37 -0
  34. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/OpenAppAction.kt +26 -0
  35. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  36. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  37. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  38. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  41. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  62. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  63. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  64. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  65. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  66. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  67. package/templates/firebase/docs/revenuecat-setup.es.md +28 -8
  68. package/templates/firebase/docs/revenuecat-setup.pt.md +28 -8
  69. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  70. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  76. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  77. package/templates/firebase/lib/components/components.dart +1 -0
  78. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  79. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  80. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  81. package/templates/firebase/lib/components/kasy_date_picker.dart +834 -0
  82. package/templates/firebase/lib/components/kasy_tabs.dart +145 -61
  83. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +45 -53
  84. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +565 -77
  85. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  86. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  87. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  88. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  89. package/templates/firebase/lib/router.dart +15 -1
  90. package/templates/firebase/pubspec.yaml +1 -1
  91. package/templates/firebase/web/index.html +9 -0
  92. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  93. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  94. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  95. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  96. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  97. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  98. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  99. package/templates/firebase/web/splash/img/light-4x.png +0 -0
@@ -0,0 +1,834 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/core/theme/theme.dart';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Data helpers
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ const List<String> _kMonthNames = [
9
+ 'January', 'February', 'March', 'April', 'May', 'June',
10
+ 'July', 'August', 'September', 'October', 'November', 'December',
11
+ ];
12
+
13
+ const List<String> _kWeekdayAbbreviations = [
14
+ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat',
15
+ ];
16
+
17
+ /// Returns the number of days in [month] of [year].
18
+ int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
19
+
20
+ /// Formats [date] as "mm / dd / yyyy".
21
+ String _formatDate(DateTime date) =>
22
+ '${date.month.toString().padLeft(2, '0')} / '
23
+ '${date.day.toString().padLeft(2, '0')} / '
24
+ '${date.year}';
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // _DayData — internal model for a single calendar cell
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ class _DayData {
31
+ const _DayData({
32
+ required this.date,
33
+ required this.isCurrentMonth,
34
+ this.isToday = false,
35
+ this.isDisabled = false,
36
+ });
37
+
38
+ final DateTime date;
39
+ final bool isCurrentMonth;
40
+ final bool isToday;
41
+ final bool isDisabled;
42
+ }
43
+
44
+ /// Builds the 42-cell (6 × 7) grid for [viewMonth].
45
+ ///
46
+ /// Cells before day 1 are filled from the previous month;
47
+ /// remaining cells after the last day are from the next month.
48
+ List<_DayData> _buildDayGrid(
49
+ DateTime viewMonth, {
50
+ DateTime? minDate,
51
+ DateTime? maxDate,
52
+ }) {
53
+ final DateTime today = DateTime.now();
54
+ final int daysInCurrent = _daysInMonth(viewMonth.year, viewMonth.month);
55
+
56
+ // Grid starts on Sunday (index 0).
57
+ // Dart weekday: 1=Mon … 7=Sun → sunday offset = weekday % 7.
58
+ final DateTime firstOfMonth = DateTime(viewMonth.year, viewMonth.month);
59
+ final int offset = firstOfMonth.weekday % 7;
60
+
61
+ // Previous-month fill.
62
+ final int prevYear =
63
+ viewMonth.month == 1 ? viewMonth.year - 1 : viewMonth.year;
64
+ final int prevMonth = viewMonth.month == 1 ? 12 : viewMonth.month - 1;
65
+ final int daysInPrev = _daysInMonth(prevYear, prevMonth);
66
+
67
+ final List<_DayData> grid = [];
68
+
69
+ // Prev-month trailing days.
70
+ for (int i = 0; i < offset; i++) {
71
+ final int d = daysInPrev - offset + 1 + i;
72
+ final DateTime date = DateTime(prevYear, prevMonth, d);
73
+ grid.add(_DayData(
74
+ date: date,
75
+ isCurrentMonth: false,
76
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
77
+ ));
78
+ }
79
+
80
+ // Current-month days.
81
+ for (int d = 1; d <= daysInCurrent; d++) {
82
+ final DateTime date = DateTime(viewMonth.year, viewMonth.month, d);
83
+ final bool isToday = date.year == today.year &&
84
+ date.month == today.month &&
85
+ date.day == today.day;
86
+ grid.add(_DayData(
87
+ date: date,
88
+ isCurrentMonth: true,
89
+ isToday: isToday,
90
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
91
+ ));
92
+ }
93
+
94
+ // Next-month leading days.
95
+ final int nextYear =
96
+ viewMonth.month == 12 ? viewMonth.year + 1 : viewMonth.year;
97
+ final int nextMonth = viewMonth.month == 12 ? 1 : viewMonth.month + 1;
98
+ int nextDay = 1;
99
+ while (grid.length < 42) {
100
+ final DateTime date = DateTime(nextYear, nextMonth, nextDay++);
101
+ grid.add(_DayData(
102
+ date: date,
103
+ isCurrentMonth: false,
104
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
105
+ ));
106
+ }
107
+
108
+ return grid;
109
+ }
110
+
111
+ bool _isDayDisabled(DateTime date, DateTime? min, DateTime? max) {
112
+ final DateTime d = DateTime(date.year, date.month, date.day);
113
+ if (min != null) {
114
+ final DateTime mn = DateTime(min.year, min.month, min.day);
115
+ if (d.isBefore(mn)) return true;
116
+ }
117
+ if (max != null) {
118
+ final DateTime mx = DateTime(max.year, max.month, max.day);
119
+ if (d.isAfter(mx)) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ bool _isSameDay(DateTime a, DateTime b) =>
125
+ a.year == b.year && a.month == b.month && a.day == b.day;
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // Design constants (from Figma variable defs)
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
131
+ // DateField: 3 drop-shadow layers — offset(0,2)blur:4, offset(0,1)blur:2, offset(0,0)blur:1
132
+ const List<BoxShadow> _kFieldShadows = [
133
+ BoxShadow(
134
+ color: Color(0x0A000000), // field/shadow rgba(0,0,0,0.04)
135
+ blurRadius: 4,
136
+ offset: Offset(0, 2),
137
+ ),
138
+ BoxShadow(
139
+ color: Color(0x0F000000), // field/shadow-2 rgba(0,0,0,0.06)
140
+ blurRadius: 2,
141
+ offset: Offset(0, 1),
142
+ ),
143
+ BoxShadow(
144
+ color: Color(0x0F000000), // field/shadow-2 rgba(0,0,0,0.06)
145
+ blurRadius: 1,
146
+ ),
147
+ ];
148
+
149
+ // Calendar overlay: shadow-overlay from Figma variable defs.
150
+ const List<BoxShadow> _kCalendarShadows = [
151
+ BoxShadow(
152
+ color: Color(0x14000000), // rgba(0,0,0,0.08)
153
+ blurRadius: 28,
154
+ offset: Offset(0, 14),
155
+ ),
156
+ BoxShadow(
157
+ color: Color(0x08000000), // rgba(0,0,0,0.03)
158
+ blurRadius: 12,
159
+ offset: Offset(0, -6),
160
+ ),
161
+ BoxShadow(
162
+ color: Color(0x0F000000), // rgba(0,0,0,0.06)
163
+ blurRadius: 8,
164
+ offset: Offset(0, 2),
165
+ ),
166
+ ];
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // KasyDatePicker
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ /// Date picker combining a text-field trigger with a floating calendar overlay.
173
+ ///
174
+ /// Tapping the field opens a calendar below the trigger. Selecting a day
175
+ /// calls [onChanged] and closes the calendar.
176
+ ///
177
+ /// Usage:
178
+ /// ```dart
179
+ /// KasyDatePicker(
180
+ /// label: 'Date',
181
+ /// value: _date,
182
+ /// onChanged: (d) => setState(() => _date = d),
183
+ /// )
184
+ /// ```
185
+ class KasyDatePicker extends StatefulWidget {
186
+ const KasyDatePicker({
187
+ super.key,
188
+ this.label,
189
+ this.value,
190
+ this.onChanged,
191
+ this.minDate,
192
+ this.maxDate,
193
+ this.enabled = true,
194
+ this.placeholder = 'mm / dd / yyyy',
195
+ });
196
+
197
+ /// Label displayed above the field. Omit to hide.
198
+ final String? label;
199
+
200
+ /// Currently selected date. Pass null for no selection.
201
+ final DateTime? value;
202
+
203
+ /// Called when the user picks a date.
204
+ final ValueChanged<DateTime>? onChanged;
205
+
206
+ /// Earliest selectable date (inclusive).
207
+ final DateTime? minDate;
208
+
209
+ /// Latest selectable date (inclusive).
210
+ final DateTime? maxDate;
211
+
212
+ final bool enabled;
213
+
214
+ /// Placeholder text shown when no date is selected.
215
+ final String placeholder;
216
+
217
+ @override
218
+ State<KasyDatePicker> createState() => _KasyDatePickerState();
219
+ }
220
+
221
+ class _KasyDatePickerState extends State<KasyDatePicker>
222
+ with SingleTickerProviderStateMixin {
223
+ final LayerLink _layerLink = LayerLink();
224
+ final OverlayPortalController _portalController = OverlayPortalController();
225
+
226
+ // Unique ID so TapRegion knows "field + calendar" form one group.
227
+ final Object _tapGroupId = Object();
228
+
229
+ // Month currently displayed in the calendar (always first of the month).
230
+ late DateTime _viewMonth;
231
+
232
+ bool _isOpen = false;
233
+
234
+ late final AnimationController _animCtrl;
235
+ late final Animation<double> _fadeAnim;
236
+ late final Animation<double> _scaleAnim;
237
+
238
+ @override
239
+ void initState() {
240
+ super.initState();
241
+ _viewMonth = _monthOf(widget.value ?? DateTime.now());
242
+ _animCtrl = AnimationController(
243
+ vsync: this,
244
+ duration: const Duration(milliseconds: 180),
245
+ );
246
+ _fadeAnim = CurvedAnimation(
247
+ parent: _animCtrl,
248
+ curve: Curves.easeOut,
249
+ reverseCurve: Curves.easeIn,
250
+ );
251
+ _scaleAnim = Tween<double>(begin: 0.95, end: 1.0).animate(
252
+ CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut),
253
+ );
254
+ }
255
+
256
+ @override
257
+ void didUpdateWidget(KasyDatePicker old) {
258
+ super.didUpdateWidget(old);
259
+ if (old.value != widget.value && widget.value != null) {
260
+ _viewMonth = _monthOf(widget.value!);
261
+ }
262
+ }
263
+
264
+ @override
265
+ void dispose() {
266
+ _animCtrl.dispose();
267
+ super.dispose();
268
+ }
269
+
270
+ DateTime _monthOf(DateTime d) => DateTime(d.year, d.month);
271
+
272
+ // ── Calendar toggle ────────────────────────────────────────────────────────
273
+
274
+ void _toggleCalendar() {
275
+ if (!widget.enabled) return;
276
+ if (_isOpen) {
277
+ _close();
278
+ } else {
279
+ _open();
280
+ }
281
+ }
282
+
283
+ void _open() {
284
+ _portalController.show();
285
+ _animCtrl.forward();
286
+ setState(() => _isOpen = true);
287
+ }
288
+
289
+ void _close() {
290
+ _animCtrl.reverse().then((_) {
291
+ if (mounted) _portalController.hide();
292
+ });
293
+ setState(() => _isOpen = false);
294
+ }
295
+
296
+ void _selectDate(DateTime date) {
297
+ widget.onChanged?.call(date);
298
+ _close();
299
+ }
300
+
301
+ void _previousMonth() {
302
+ setState(() {
303
+ _viewMonth = DateTime(
304
+ _viewMonth.month == 1 ? _viewMonth.year - 1 : _viewMonth.year,
305
+ _viewMonth.month == 1 ? 12 : _viewMonth.month - 1,
306
+ );
307
+ });
308
+ }
309
+
310
+ void _nextMonth() {
311
+ setState(() {
312
+ _viewMonth = DateTime(
313
+ _viewMonth.month == 12 ? _viewMonth.year + 1 : _viewMonth.year,
314
+ _viewMonth.month == 12 ? 1 : _viewMonth.month + 1,
315
+ );
316
+ });
317
+ }
318
+
319
+ // ── Build ──────────────────────────────────────────────────────────────────
320
+
321
+ @override
322
+ Widget build(BuildContext context) {
323
+ return TapRegion(
324
+ groupId: _tapGroupId,
325
+ child: OverlayPortal(
326
+ controller: _portalController,
327
+ overlayChildBuilder: _buildOverlay,
328
+ child: CompositedTransformTarget(
329
+ link: _layerLink,
330
+ child: GestureDetector(
331
+ onTap: _toggleCalendar,
332
+ child: _buildField(context),
333
+ ),
334
+ ),
335
+ ),
336
+ );
337
+ }
338
+
339
+ // ── DateField trigger ──────────────────────────────────────────────────────
340
+
341
+ Widget _buildField(BuildContext context) {
342
+ final KasyColors c = context.colors;
343
+ final DateTime? date = widget.value;
344
+ final bool hasDate = date != null;
345
+
346
+ return Column(
347
+ crossAxisAlignment: CrossAxisAlignment.start,
348
+ mainAxisSize: MainAxisSize.min,
349
+ children: [
350
+ if (widget.label != null) ...[
351
+ Text(
352
+ widget.label!,
353
+ style: context.textTheme.labelLarge?.copyWith(
354
+ color: c.onSurface,
355
+ fontWeight: FontWeight.w500,
356
+ fontSize: 14,
357
+ ),
358
+ ),
359
+ // 4px gap between label and field — per Figma spec.
360
+ const SizedBox(height: 4),
361
+ ],
362
+ AnimatedContainer(
363
+ duration: const Duration(milliseconds: 150),
364
+ // Field height: 36px — per Figma spec (dimensions/spacing/9).
365
+ height: 36,
366
+ decoration: BoxDecoration(
367
+ color: c.surface,
368
+ // 12px radius — per Figma spec (field/radius: 12).
369
+ borderRadius: BorderRadius.circular(KasyRadius.md),
370
+ // Border is always 1px transparent — the focus ring uses
371
+ // box-shadow spread (not border-width) so layout never shifts.
372
+ border: Border.all(color: Colors.transparent),
373
+ // Open: solid 2px primary ring only (no field shadows while
374
+ // focused — matches Figma focus-ring-field token).
375
+ // Closed: three-layer depth shadow only (no ring).
376
+ boxShadow: _isOpen
377
+ ? [
378
+ BoxShadow(
379
+ color: c.primary,
380
+ spreadRadius: 2,
381
+ ),
382
+ ]
383
+ : _kFieldShadows,
384
+ ),
385
+ child: Row(
386
+ children: [
387
+ // 12px left padding — per Figma spec (dimensions/spacing/3).
388
+ const SizedBox(width: 12),
389
+ Expanded(
390
+ child: Text(
391
+ hasDate ? _formatDate(date) : widget.placeholder,
392
+ style: context.textTheme.labelLarge?.copyWith(
393
+ color: hasDate ? c.onSurface : c.muted,
394
+ fontWeight:
395
+ hasDate ? FontWeight.w500 : FontWeight.w400,
396
+ fontSize: 14,
397
+ ),
398
+ ),
399
+ ),
400
+ // Calendar icon suffix — 12px padding per Figma spec.
401
+ SizedBox(
402
+ width: 40,
403
+ height: 36,
404
+ child: Center(
405
+ child: Opacity(
406
+ opacity: widget.enabled ? 1.0 : 0.4,
407
+ child: Icon(
408
+ KasyIcons.calendar,
409
+ size: 16,
410
+ color: c.muted,
411
+ ),
412
+ ),
413
+ ),
414
+ ),
415
+ ],
416
+ ),
417
+ ),
418
+ ],
419
+ );
420
+ }
421
+
422
+ // ── Calendar overlay ───────────────────────────────────────────────────────
423
+
424
+ Widget _buildOverlay(BuildContext context) {
425
+ return TapRegion(
426
+ groupId: _tapGroupId,
427
+ onTapOutside: (_) => _close(),
428
+ child: CompositedTransformFollower(
429
+ link: _layerLink,
430
+ targetAnchor: Alignment.bottomLeft,
431
+ // 8px gap between field bottom and calendar top — per Figma spec.
432
+ offset: const Offset(0, 8),
433
+ child: Align(
434
+ alignment: Alignment.topLeft,
435
+ child: FadeTransition(
436
+ opacity: _fadeAnim,
437
+ child: ScaleTransition(
438
+ scale: _scaleAnim,
439
+ alignment: Alignment.topCenter,
440
+ child: _CalendarPanel(
441
+ viewMonth: _viewMonth,
442
+ selectedDate: widget.value,
443
+ onDateSelected: _selectDate,
444
+ onPreviousMonth: _previousMonth,
445
+ onNextMonth: _nextMonth,
446
+ minDate: widget.minDate,
447
+ maxDate: widget.maxDate,
448
+ ),
449
+ ),
450
+ ),
451
+ ),
452
+ ),
453
+ );
454
+ }
455
+ }
456
+
457
+ // ─────────────────────────────────────────────────────────────────────────────
458
+ // _CalendarPanel
459
+ // ─────────────────────────────────────────────────────────────────────────────
460
+
461
+ class _CalendarPanel extends StatelessWidget {
462
+ const _CalendarPanel({
463
+ required this.viewMonth,
464
+ required this.selectedDate,
465
+ required this.onDateSelected,
466
+ required this.onPreviousMonth,
467
+ required this.onNextMonth,
468
+ this.minDate,
469
+ this.maxDate,
470
+ });
471
+
472
+ final DateTime viewMonth;
473
+ final DateTime? selectedDate;
474
+ final ValueChanged<DateTime> onDateSelected;
475
+ final VoidCallback onPreviousMonth;
476
+ final VoidCallback onNextMonth;
477
+ final DateTime? minDate;
478
+ final DateTime? maxDate;
479
+
480
+ @override
481
+ Widget build(BuildContext context) {
482
+ final KasyColors c = context.colors;
483
+ final List<_DayData> days = _buildDayGrid(
484
+ viewMonth,
485
+ minDate: minDate,
486
+ maxDate: maxDate,
487
+ );
488
+
489
+ return Material(
490
+ color: Colors.transparent,
491
+ child: Container(
492
+ // Width matches the DateField — 288px per Figma spec.
493
+ width: 288,
494
+ decoration: BoxDecoration(
495
+ color: c.surface,
496
+ // 24px radius — per Figma spec (dimensions/radius/rounded-3xl: 24).
497
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
498
+ boxShadow: _kCalendarShadows,
499
+ ),
500
+ child: Padding(
501
+ // 16px all sides — per Figma spec (dimensions/spacing/4: 16).
502
+ padding: const EdgeInsets.all(KasySpacing.md),
503
+ child: Column(
504
+ mainAxisSize: MainAxisSize.min,
505
+ children: [
506
+ // Navigation row (month/year + prev/next arrows).
507
+ _CalendarNavRow(
508
+ viewMonth: viewMonth,
509
+ onPrevious: onPreviousMonth,
510
+ onNext: onNextMonth,
511
+ ),
512
+ // Weekday abbreviation headers.
513
+ const _WeekdayHeaderRow(),
514
+ // Day grid: 6 rows × 7 columns, 2px gap between rows.
515
+ for (int row = 0; row < 6; row++) ...[
516
+ if (row > 0) const SizedBox(height: 2),
517
+ _DayRow(
518
+ days: days.sublist(row * 7, row * 7 + 7),
519
+ selectedDate: selectedDate,
520
+ onDateSelected: onDateSelected,
521
+ ),
522
+ ],
523
+ ],
524
+ ),
525
+ ),
526
+ ),
527
+ );
528
+ }
529
+ }
530
+
531
+ // ─────────────────────────────────────────────────────────────────────────────
532
+ // _CalendarNavRow — Month/year header with prev/next navigation
533
+ // ─────────────────────────────────────────────────────────────────────────────
534
+
535
+ class _CalendarNavRow extends StatelessWidget {
536
+ const _CalendarNavRow({
537
+ required this.viewMonth,
538
+ required this.onPrevious,
539
+ required this.onNext,
540
+ });
541
+
542
+ final DateTime viewMonth;
543
+ final VoidCallback onPrevious;
544
+ final VoidCallback onNext;
545
+
546
+ @override
547
+ Widget build(BuildContext context) {
548
+ final KasyColors c = context.colors;
549
+
550
+ return SizedBox(
551
+ // Navigation row height: 56px (16px padding top+bottom) — Figma spec.
552
+ height: 56,
553
+ child: Row(
554
+ children: [
555
+ // Month + Year text.
556
+ Expanded(
557
+ child: Row(
558
+ children: [
559
+ Text(
560
+ _kMonthNames[viewMonth.month - 1],
561
+ style: context.textTheme.labelLarge?.copyWith(
562
+ color: c.onSurface,
563
+ fontWeight: FontWeight.w500,
564
+ fontSize: 14,
565
+ ),
566
+ ),
567
+ const SizedBox(width: 6),
568
+ Text(
569
+ viewMonth.year.toString(),
570
+ style: context.textTheme.labelLarge?.copyWith(
571
+ color: c.onSurface,
572
+ fontWeight: FontWeight.w500,
573
+ fontSize: 14,
574
+ ),
575
+ ),
576
+ const SizedBox(width: 4),
577
+ // Expand chevron (right arrow, 12px) — indicates year picker.
578
+ Icon(
579
+ KasyIcons.chevronRight,
580
+ size: 12,
581
+ color: c.muted,
582
+ ),
583
+ ],
584
+ ),
585
+ ),
586
+ // Previous-month arrow button.
587
+ _NavArrowButton(
588
+ icon: KasyIcons.arrowBackIos,
589
+ onTap: onPrevious,
590
+ ),
591
+ const SizedBox(width: 4),
592
+ // Next-month arrow button.
593
+ _NavArrowButton(
594
+ icon: KasyIcons.arrowForwardIos,
595
+ onTap: onNext,
596
+ ),
597
+ ],
598
+ ),
599
+ );
600
+ }
601
+ }
602
+
603
+ // ─────────────────────────────────────────────────────────────────────────────
604
+ // _NavArrowButton — 24×24 rounded navigation arrow
605
+ // ─────────────────────────────────────────────────────────────────────────────
606
+
607
+ class _NavArrowButton extends StatefulWidget {
608
+ const _NavArrowButton({required this.icon, required this.onTap});
609
+
610
+ final IconData icon;
611
+ final VoidCallback onTap;
612
+
613
+ @override
614
+ State<_NavArrowButton> createState() => _NavArrowButtonState();
615
+ }
616
+
617
+ class _NavArrowButtonState extends State<_NavArrowButton> {
618
+ bool _hovered = false;
619
+
620
+ @override
621
+ Widget build(BuildContext context) {
622
+ final KasyColors c = context.colors;
623
+
624
+ return GestureDetector(
625
+ onTap: widget.onTap,
626
+ onTapDown: (_) => setState(() => _hovered = true),
627
+ onTapUp: (_) => setState(() => _hovered = false),
628
+ onTapCancel: () => setState(() => _hovered = false),
629
+ child: AnimatedContainer(
630
+ duration: const Duration(milliseconds: 100),
631
+ // Button size: 24×24 — per Figma spec.
632
+ width: 24,
633
+ height: 24,
634
+ // 4px padding → 6px radius — per Figma (rounded-md: 6).
635
+ decoration: BoxDecoration(
636
+ color: _hovered ? c.avatarFallbackFill : Colors.transparent,
637
+ borderRadius: BorderRadius.circular(6),
638
+ ),
639
+ child: Center(
640
+ child: Icon(
641
+ widget.icon,
642
+ size: 16,
643
+ color: c.onSurface,
644
+ ),
645
+ ),
646
+ ),
647
+ );
648
+ }
649
+ }
650
+
651
+ // ─────────────────────────────────────────────────────────────────────────────
652
+ // _WeekdayHeaderRow — Sun Mon Tue Wed Thu Fri Sat
653
+ // ─────────────────────────────────────────────────────────────────────────────
654
+
655
+ class _WeekdayHeaderRow extends StatelessWidget {
656
+ const _WeekdayHeaderRow();
657
+
658
+ @override
659
+ Widget build(BuildContext context) {
660
+ final KasyColors c = context.colors;
661
+
662
+ return Row(
663
+ children: _kWeekdayAbbreviations.map((abbr) {
664
+ return Expanded(
665
+ child: SizedBox(
666
+ height: 36,
667
+ child: Center(
668
+ child: Text(
669
+ abbr,
670
+ style: context.textTheme.labelMedium?.copyWith(
671
+ // 12px/w500 — per Figma spec (Body xs medium: text-xs).
672
+ color: c.muted,
673
+ fontSize: 12,
674
+ fontWeight: FontWeight.w500,
675
+ ),
676
+ ),
677
+ ),
678
+ ),
679
+ );
680
+ }).toList(),
681
+ );
682
+ }
683
+ }
684
+
685
+ // ─────────────────────────────────────────────────────────────────────────────
686
+ // _DayRow — one week row (7 day cells)
687
+ // ─────────────────────────────────────────────────────────────────────────────
688
+
689
+ class _DayRow extends StatelessWidget {
690
+ const _DayRow({
691
+ required this.days,
692
+ required this.selectedDate,
693
+ required this.onDateSelected,
694
+ });
695
+
696
+ final List<_DayData> days;
697
+ final DateTime? selectedDate;
698
+ final ValueChanged<DateTime> onDateSelected;
699
+
700
+ @override
701
+ Widget build(BuildContext context) {
702
+ return Row(
703
+ children: days.map((day) {
704
+ final bool isSelected =
705
+ selectedDate != null && _isSameDay(day.date, selectedDate!);
706
+ return Expanded(
707
+ child: _DayCell(
708
+ data: day,
709
+ isSelected: isSelected,
710
+ onTap: (day.isDisabled || !day.isCurrentMonth && day.isDisabled)
711
+ ? null
712
+ : () => onDateSelected(day.date),
713
+ ),
714
+ );
715
+ }).toList(),
716
+ );
717
+ }
718
+ }
719
+
720
+ // ─────────────────────────────────────────────────────────────────────────────
721
+ // _DayCell — individual calendar day
722
+ // ─────────────────────────────────────────────────────────────────────────────
723
+
724
+ class _DayCell extends StatefulWidget {
725
+ const _DayCell({
726
+ required this.data,
727
+ required this.isSelected,
728
+ required this.onTap,
729
+ });
730
+
731
+ final _DayData data;
732
+ final bool isSelected;
733
+ final VoidCallback? onTap;
734
+
735
+ @override
736
+ State<_DayCell> createState() => _DayCellState();
737
+ }
738
+
739
+ class _DayCellState extends State<_DayCell> {
740
+ bool _pressed = false;
741
+
742
+ @override
743
+ Widget build(BuildContext context) {
744
+ final KasyColors c = context.colors;
745
+ final _DayData day = widget.data;
746
+
747
+ // ── Color determination ────────────────────────────────────────────────
748
+
749
+ final Color textColor;
750
+ final Color? bgColor;
751
+
752
+ if (widget.isSelected) {
753
+ textColor = const Color(0xFFFCFCFC); // accent/accent-foreground
754
+ bgColor = c.primary;
755
+ } else if (day.isToday) {
756
+ textColor = c.primary; // accent/accent-soft-foreground
757
+ bgColor = _pressed ? c.avatarFallbackFill : null;
758
+ } else if (!day.isCurrentMonth) {
759
+ textColor = c.muted; // out-of-month
760
+ bgColor = _pressed ? c.avatarFallbackFill : null;
761
+ } else if (day.isDisabled) {
762
+ textColor = c.muted;
763
+ bgColor = null;
764
+ } else {
765
+ textColor = c.onSurface; // default
766
+ bgColor = _pressed ? c.avatarFallbackFill : null;
767
+ }
768
+
769
+ // ── Today indicator dot ────────────────────────────────────────────────
770
+ // 3px dot, positioned 4px from bottom — Figma spec.
771
+ // Hidden when the day is selected (blue circle already communicates
772
+ // selection clearly — dot would be redundant).
773
+ final bool showDot = day.isToday && !widget.isSelected;
774
+ final Color dotColor = widget.isSelected
775
+ ? const Color(0xFFFCFCFC)
776
+ : c.muted;
777
+
778
+ // ── Opacity ────────────────────────────────────────────────────────────
779
+ // Out-of-month and disabled cells use 50% opacity — Figma spec.
780
+ final double opacity =
781
+ (!day.isCurrentMonth || day.isDisabled) ? 0.5 : 1.0;
782
+
783
+ final Widget cell = AnimatedContainer(
784
+ duration: const Duration(milliseconds: 100),
785
+ // Cell size: 36×36 — Figma spec (dimensions/spacing/9: 36).
786
+ width: 36,
787
+ height: 36,
788
+ decoration: BoxDecoration(
789
+ color: bgColor,
790
+ // 24px radius → pill/circle — Figma spec (rounded-3xl: 24).
791
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
792
+ ),
793
+ child: Stack(
794
+ alignment: Alignment.center,
795
+ children: [
796
+ // Day number.
797
+ Text(
798
+ day.date.day.toString(),
799
+ style: context.textTheme.labelLarge?.copyWith(
800
+ color: textColor,
801
+ fontWeight: FontWeight.w500,
802
+ fontSize: 14,
803
+ ),
804
+ ),
805
+ // Today indicator dot — 3px circle at bottom center.
806
+ if (showDot)
807
+ Positioned(
808
+ bottom: 4,
809
+ child: Container(
810
+ width: 3,
811
+ height: 3,
812
+ decoration: BoxDecoration(
813
+ color: dotColor,
814
+ borderRadius: BorderRadius.circular(12),
815
+ ),
816
+ ),
817
+ ),
818
+ ],
819
+ ),
820
+ );
821
+
822
+ if (widget.onTap == null) {
823
+ return Opacity(opacity: opacity, child: Center(child: cell));
824
+ }
825
+
826
+ return GestureDetector(
827
+ onTap: widget.onTap,
828
+ onTapDown: (_) => setState(() => _pressed = true),
829
+ onTapUp: (_) => setState(() => _pressed = false),
830
+ onTapCancel: () => setState(() => _pressed = false),
831
+ child: Opacity(opacity: opacity, child: Center(child: cell)),
832
+ );
833
+ }
834
+ }