kasy-cli 1.17.0 → 1.19.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 (110) hide show
  1. package/bin/kasy.js +16 -2
  2. package/lib/commands/add.js +7 -7
  3. package/lib/commands/configure.js +548 -0
  4. package/lib/commands/deploy.js +4 -4
  5. package/lib/commands/doctor.js +17 -0
  6. package/lib/commands/favicon.js +4 -4
  7. package/lib/commands/icon.js +5 -5
  8. package/lib/commands/new.js +483 -324
  9. package/lib/commands/run.js +17 -4
  10. package/lib/commands/splash.js +5 -5
  11. package/lib/commands/update.js +9 -9
  12. package/lib/scaffold/CHANGELOG.json +14 -0
  13. package/lib/scaffold/backends/firebase/enable-auth-via-cli.js +108 -0
  14. package/lib/scaffold/backends/firebase/setup-from-scratch.js +123 -5
  15. package/lib/scaffold/generate.js +24 -8
  16. package/lib/scaffold/shared/post-build.js +8 -0
  17. package/lib/utils/brand.js +16 -12
  18. package/lib/utils/flutter-run.js +139 -11
  19. package/lib/utils/i18n/messages-en.js +62 -5
  20. package/lib/utils/i18n/messages-es.js +62 -5
  21. package/lib/utils/i18n/messages-pt.js +63 -6
  22. package/lib/utils/ui.js +79 -4
  23. package/package.json +1 -2
  24. package/templates/firebase/README.en.md +1 -1
  25. package/templates/firebase/README.es.md +1 -1
  26. package/templates/firebase/README.md +1 -1
  27. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +0 -15
  28. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +65 -26
  29. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +2 -2
  30. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +7 -2
  31. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +6 -6
  32. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +1 -1
  33. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +2 -2
  34. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +1 -1
  35. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  36. package/templates/firebase/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  37. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  38. package/templates/firebase/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  39. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  40. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/splash.png +0 -0
  41. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  42. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/splash.png +0 -0
  43. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  44. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/splash.png +0 -0
  45. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  46. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/splash.png +0 -0
  47. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/splash.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +3 -3
  56. package/templates/firebase/android/app/src/main/res/values/colors.xml +32 -0
  57. package/templates/firebase/assets/images/splash_logo_dark.png +0 -0
  58. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  59. package/templates/firebase/assets/images/splash_logo_light.png +0 -0
  60. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  61. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +75 -29
  62. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png +0 -0
  68. package/templates/firebase/ios/Runner/Base.lproj/LaunchScreen.storyboard +1 -1
  69. package/templates/firebase/lib/components/components.dart +1 -0
  70. package/templates/firebase/lib/components/kasy_avatar.dart +65 -17
  71. package/templates/firebase/lib/components/kasy_avatar_presets.dart +121 -97
  72. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  73. package/templates/firebase/lib/components/kasy_date_picker.dart +2173 -0
  74. package/templates/firebase/lib/components/kasy_tabs.dart +214 -91
  75. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  76. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  77. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  78. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  79. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  80. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  81. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  82. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +12 -18
  83. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  84. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  85. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  86. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  87. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  88. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  89. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  90. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +949 -77
  91. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  92. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  93. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  94. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +10 -5
  95. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  96. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  97. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
  98. package/templates/firebase/lib/main.dart +34 -34
  99. package/templates/firebase/pubspec.yaml +2 -1
  100. package/templates/firebase/storage.cors.json +8 -0
  101. package/templates/firebase/web/index.html +24 -2
  102. package/templates/firebase/web/splash/img/dark-1x.png +0 -0
  103. package/templates/firebase/web/splash/img/dark-2x.png +0 -0
  104. package/templates/firebase/web/splash/img/dark-3x.png +0 -0
  105. package/templates/firebase/web/splash/img/dark-4x.png +0 -0
  106. package/templates/firebase/web/splash/img/light-1x.png +0 -0
  107. package/templates/firebase/web/splash/img/light-2x.png +0 -0
  108. package/templates/firebase/web/splash/img/light-3x.png +0 -0
  109. package/templates/firebase/web/splash/img/light-4x.png +0 -0
  110. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
@@ -0,0 +1,2173 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
3
+ import 'package:kasy_kit/components/kasy_dialog.dart';
4
+ import 'package:kasy_kit/components/kasy_text_field.dart';
5
+ import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Data helpers
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ const List<String> _kMonthNames = [
13
+ 'January', 'February', 'March', 'April', 'May', 'June',
14
+ 'July', 'August', 'September', 'October', 'November', 'December',
15
+ ];
16
+
17
+ const List<String> _kWeekdayAbbreviations = [
18
+ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat',
19
+ ];
20
+
21
+ /// Returns the number of days in [month] of [year].
22
+ int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // KasyDateFormat — built-in regional formatting presets
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ /// Built-in date display formats. Pick the one that matches the user's region,
29
+ /// or pass [KasyDatePicker.formatDate] for a fully custom layout.
30
+ enum KasyDateFormat {
31
+ /// MM / DD / YYYY — United States (en-US).
32
+ us,
33
+
34
+ /// DD / MM / YYYY — South America, UK, France, Italy, Spain, Portugal, and
35
+ /// most of Europe. Default for the [KasyDatePickerLocale.es] and
36
+ /// [KasyDatePickerLocale.pt] locales.
37
+ southAmerican,
38
+
39
+ /// DD.MM.YYYY — Germany, Austria, Switzerland, Netherlands.
40
+ european,
41
+
42
+ /// YYYY-MM-DD — ISO 8601 international standard, common in APIs and logs.
43
+ iso,
44
+
45
+ /// YYYY / MM / DD — Japan, China, Korea (East Asian convention).
46
+ eastAsian,
47
+ }
48
+
49
+ String _formatWithPreset(DateTime date, KasyDateFormat preset) {
50
+ final String mm = date.month.toString().padLeft(2, '0');
51
+ final String dd = date.day.toString().padLeft(2, '0');
52
+ final String yyyy = date.year.toString();
53
+ switch (preset) {
54
+ case KasyDateFormat.us:
55
+ return '$mm / $dd / $yyyy';
56
+ case KasyDateFormat.southAmerican:
57
+ return '$dd / $mm / $yyyy';
58
+ case KasyDateFormat.european:
59
+ return '$dd.$mm.$yyyy';
60
+ case KasyDateFormat.iso:
61
+ return '$yyyy-$mm-$dd';
62
+ case KasyDateFormat.eastAsian:
63
+ return '$yyyy / $mm / $dd';
64
+ }
65
+ }
66
+
67
+ String _placeholderFor(KasyDateFormat preset) {
68
+ switch (preset) {
69
+ case KasyDateFormat.us:
70
+ return 'mm / dd / yyyy';
71
+ case KasyDateFormat.southAmerican:
72
+ return 'dd / mm / yyyy';
73
+ case KasyDateFormat.european:
74
+ return 'dd.mm.yyyy';
75
+ case KasyDateFormat.iso:
76
+ return 'yyyy-mm-dd';
77
+ case KasyDateFormat.eastAsian:
78
+ return 'yyyy / mm / dd';
79
+ }
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // _DayData — internal model for a single calendar cell
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ class _DayData {
87
+ const _DayData({
88
+ required this.date,
89
+ required this.isCurrentMonth,
90
+ this.isToday = false,
91
+ this.isDisabled = false,
92
+ });
93
+
94
+ final DateTime date;
95
+ final bool isCurrentMonth;
96
+ final bool isToday;
97
+ final bool isDisabled;
98
+ }
99
+
100
+ /// Builds the 42-cell (6 × 7) grid for [viewMonth].
101
+ ///
102
+ /// Cells before day 1 are filled from the previous month;
103
+ /// remaining cells after the last day are from the next month.
104
+ List<_DayData> _buildDayGrid(
105
+ DateTime viewMonth, {
106
+ DateTime? minDate,
107
+ DateTime? maxDate,
108
+ int weekStartDay = 0,
109
+ }) {
110
+ final DateTime today = DateTime.now();
111
+ final int daysInCurrent = _daysInMonth(viewMonth.year, viewMonth.month);
112
+
113
+ // Dart weekday: Mon=1 … Sun=7.
114
+ // Convert to Sunday-origin (Sun=0, Mon=1, …, Sat=6), then shift by weekStartDay.
115
+ // weekStartDay 0 = Sunday-first, 1 = Monday-first.
116
+ final DateTime firstOfMonth = DateTime(viewMonth.year, viewMonth.month);
117
+ final int offset = (firstOfMonth.weekday % 7 - weekStartDay + 7) % 7;
118
+
119
+ // Previous-month fill.
120
+ final int prevYear =
121
+ viewMonth.month == 1 ? viewMonth.year - 1 : viewMonth.year;
122
+ final int prevMonth = viewMonth.month == 1 ? 12 : viewMonth.month - 1;
123
+ final int daysInPrev = _daysInMonth(prevYear, prevMonth);
124
+
125
+ final List<_DayData> grid = [];
126
+
127
+ // Prev-month trailing days.
128
+ for (int i = 0; i < offset; i++) {
129
+ final int d = daysInPrev - offset + 1 + i;
130
+ final DateTime date = DateTime(prevYear, prevMonth, d);
131
+ grid.add(_DayData(
132
+ date: date,
133
+ isCurrentMonth: false,
134
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
135
+ ));
136
+ }
137
+
138
+ // Current-month days.
139
+ for (int d = 1; d <= daysInCurrent; d++) {
140
+ final DateTime date = DateTime(viewMonth.year, viewMonth.month, d);
141
+ final bool isToday = date.year == today.year &&
142
+ date.month == today.month &&
143
+ date.day == today.day;
144
+ grid.add(_DayData(
145
+ date: date,
146
+ isCurrentMonth: true,
147
+ isToday: isToday,
148
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
149
+ ));
150
+ }
151
+
152
+ // Next-month leading days.
153
+ final int nextYear =
154
+ viewMonth.month == 12 ? viewMonth.year + 1 : viewMonth.year;
155
+ final int nextMonth = viewMonth.month == 12 ? 1 : viewMonth.month + 1;
156
+ int nextDay = 1;
157
+ while (grid.length < 42) {
158
+ final DateTime date = DateTime(nextYear, nextMonth, nextDay++);
159
+ grid.add(_DayData(
160
+ date: date,
161
+ isCurrentMonth: false,
162
+ isDisabled: _isDayDisabled(date, minDate, maxDate),
163
+ ));
164
+ }
165
+
166
+ return grid;
167
+ }
168
+
169
+ bool _isDayDisabled(DateTime date, DateTime? min, DateTime? max) {
170
+ final DateTime d = DateTime(date.year, date.month, date.day);
171
+ if (min != null) {
172
+ final DateTime mn = DateTime(min.year, min.month, min.day);
173
+ if (d.isBefore(mn)) return true;
174
+ }
175
+ if (max != null) {
176
+ final DateTime mx = DateTime(max.year, max.month, max.day);
177
+ if (d.isAfter(mx)) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ bool _isSameDay(DateTime a, DateTime b) =>
183
+ a.year == b.year && a.month == b.month && a.day == b.day;
184
+
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ // Design constants (from Figma variable defs)
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ // Calendar overlay shadow — from Figma variable defs (3 layers).
190
+ const List<BoxShadow> _kCalendarShadows = [
191
+ BoxShadow(
192
+ color: Color(0x14000000), // rgba(0,0,0,0.08)
193
+ blurRadius: 28,
194
+ offset: Offset(0, 14),
195
+ ),
196
+ BoxShadow(
197
+ color: Color(0x07000000), // rgba(0,0,0,0.027)
198
+ blurRadius: 12,
199
+ offset: Offset(0, -6),
200
+ ),
201
+ BoxShadow(
202
+ color: Color(0x0F000000), // rgba(0,0,0,0.06)
203
+ blurRadius: 8,
204
+ offset: Offset(0, 2),
205
+ ),
206
+ ];
207
+
208
+
209
+ // ─────────────────────────────────────────────────────────────────────────────
210
+ // KasyDatePickerLocale — calendar display locale
211
+ // ─────────────────────────────────────────────────────────────────────────────
212
+
213
+ /// Controls month names, weekday labels, week start, and month/year separator
214
+ /// inside the [KasyDatePicker] calendar overlay.
215
+ ///
216
+ /// Two built-in presets are provided: [KasyDatePickerLocale.en] and
217
+ /// [KasyDatePickerLocale.es]. For other locales, construct a custom instance.
218
+ class KasyDatePickerLocale {
219
+ const KasyDatePickerLocale({
220
+ required this.monthNames,
221
+ required this.weekdayAbbreviations,
222
+ this.weekStartDay = 0,
223
+ this.monthYearSeparator = ' ',
224
+ this.defaultDateFormat = KasyDateFormat.us,
225
+ });
226
+
227
+ /// Full month names — 12 entries (January … December order).
228
+ final List<String> monthNames;
229
+
230
+ /// Weekday header abbreviations — 7 entries ordered from [weekStartDay].
231
+ /// For Sunday-start: Sun Mon Tue Wed Thu Fri Sat.
232
+ /// For Monday-start: Mon Tue Wed Thu Fri Sat Sun.
233
+ final List<String> weekdayAbbreviations;
234
+
235
+ /// First day of the week shown in the calendar grid.
236
+ /// 0 = Sunday (default, en-US), 1 = Monday (most of the world).
237
+ final int weekStartDay;
238
+
239
+ /// String inserted between the month name and year in the calendar header.
240
+ /// English: ' ' → "May 2026". Spanish: ' de ' → "mayo de 2026".
241
+ final String monthYearSeparator;
242
+
243
+ /// Region-appropriate display format used when [KasyDatePicker.dateFormat]
244
+ /// and [KasyDatePicker.formatDate] are both null. Defaults to
245
+ /// [KasyDateFormat.us] for English and [KasyDateFormat.southAmerican] for
246
+ /// Spanish and Portuguese.
247
+ final KasyDateFormat defaultDateFormat;
248
+
249
+ /// English locale — Sunday-first week, US date format.
250
+ static const KasyDatePickerLocale en = KasyDatePickerLocale(
251
+ monthNames: _kMonthNames,
252
+ weekdayAbbreviations: _kWeekdayAbbreviations,
253
+ );
254
+
255
+ /// Spanish locale — Monday-first week, South-American date format.
256
+ static const KasyDatePickerLocale es = KasyDatePickerLocale(
257
+ monthNames: [
258
+ 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
259
+ 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
260
+ ],
261
+ weekdayAbbreviations: ['lun', 'mar', 'mié', 'jue', 'vie', 'sáb', 'dom'],
262
+ weekStartDay: 1,
263
+ monthYearSeparator: ' de ',
264
+ defaultDateFormat: KasyDateFormat.southAmerican,
265
+ );
266
+
267
+ /// Portuguese (Brazil) locale — Monday-first week, South-American date format.
268
+ static const KasyDatePickerLocale pt = KasyDatePickerLocale(
269
+ monthNames: [
270
+ 'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
271
+ 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro',
272
+ ],
273
+ weekdayAbbreviations: ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom'],
274
+ weekStartDay: 1,
275
+ monthYearSeparator: ' de ',
276
+ defaultDateFormat: KasyDateFormat.southAmerican,
277
+ );
278
+ }
279
+
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // KasyDatePickerNavStyle — header layout of the calendar's nav row
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+
284
+ /// Visual layout for the calendar's month-navigation row.
285
+ enum KasyDatePickerNavStyle {
286
+ /// Arrows on the outer edges, month/year centered between them
287
+ /// ("‹ December 2025 ›"). Default — matches modern desktop calendars
288
+ /// (Airbnb, Google Calendar) and is also used as the multi-month layout
289
+ /// where arrows wrap the row of month captions.
290
+ multi,
291
+
292
+ /// Month/year on the left, prev/next arrows clustered on the right
293
+ /// ("December 2025 … ‹ ›"). Compact, mirrors iOS native pickers.
294
+ single,
295
+ }
296
+
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+ // KasyDatePickerPresentation — controls how the calendar is shown
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+
301
+ /// Controls how the calendar is presented when the date field is tapped.
302
+ enum KasyDatePickerPresentation {
303
+ /// Floating overlay anchored below the trigger field (default).
304
+ popover,
305
+
306
+ /// Modal dialog centered on screen.
307
+ dialog,
308
+
309
+ /// Modal bottom sheet sliding up from the bottom.
310
+ bottomSheet,
311
+ }
312
+
313
+ // ─────────────────────────────────────────────────────────────────────────────
314
+ // KasyDateSelectionMode + KasyDateRange — single vs. start/end selection
315
+ // ─────────────────────────────────────────────────────────────────────────────
316
+
317
+ /// How the user selects dates inside the calendar.
318
+ enum KasyDateSelectionMode {
319
+ /// One tap commits a single date (default — back-compat with [value]).
320
+ single,
321
+
322
+ /// Two taps commit a start + end date (Airbnb-style range selection). The
323
+ /// calendar stays open until both endpoints are picked, then auto-closes.
324
+ range,
325
+ }
326
+
327
+ /// Immutable start/end pair used in [KasyDateSelectionMode.range]. Either
328
+ /// endpoint can be null while the user is still picking.
329
+ class KasyDateRange {
330
+ const KasyDateRange({this.start, this.end});
331
+
332
+ final DateTime? start;
333
+ final DateTime? end;
334
+
335
+ /// True when both endpoints are set — used by the picker to know it can
336
+ /// close the calendar.
337
+ bool get isComplete => start != null && end != null;
338
+
339
+ /// True when at least the start is set.
340
+ bool get hasStart => start != null;
341
+
342
+ @override
343
+ bool operator ==(Object other) =>
344
+ identical(this, other) ||
345
+ other is KasyDateRange && other.start == start && other.end == end;
346
+
347
+ @override
348
+ int get hashCode => Object.hash(start, end);
349
+ }
350
+
351
+ // ─────────────────────────────────────────────────────────────────────────────
352
+ // _CalendarViewMode — month grid vs. year picker
353
+ // ─────────────────────────────────────────────────────────────────────────────
354
+
355
+ enum _CalendarViewMode { month, year }
356
+
357
+ // ─────────────────────────────────────────────────────────────────────────────
358
+ // _RangePosition — where a day sits inside a [KasyDateRange]
359
+ // ─────────────────────────────────────────────────────────────────────────────
360
+ //
361
+ // Drives the Airbnb-style highlight:
362
+ // none → no background, default text color
363
+ // start → primary circle + soft bar extending to the right edge
364
+ // middle → soft bar full-width (no cell radius, connects start↔end)
365
+ // end → primary circle + soft bar extending to the left edge
366
+ // single → primary circle alone (single-date pick OR range with start==end)
367
+ enum _RangePosition { none, start, middle, end, single }
368
+
369
+ _RangePosition _positionFor(DateTime day, KasyDateRange? range) {
370
+ if (range == null || range.start == null) return _RangePosition.none;
371
+ final DateTime start = range.start!;
372
+ final DateTime? end = range.end;
373
+ if (end == null) {
374
+ return _isSameDay(day, start) ? _RangePosition.single : _RangePosition.none;
375
+ }
376
+ if (_isSameDay(start, end)) {
377
+ return _isSameDay(day, start) ? _RangePosition.single : _RangePosition.none;
378
+ }
379
+ if (_isSameDay(day, start)) return _RangePosition.start;
380
+ if (_isSameDay(day, end)) return _RangePosition.end;
381
+ // Strictly between — use day-only comparison so time components don't break.
382
+ final DateTime d = DateTime(day.year, day.month, day.day);
383
+ final DateTime s = DateTime(start.year, start.month, start.day);
384
+ final DateTime e = DateTime(end.year, end.month, end.day);
385
+ if (d.isAfter(s) && d.isBefore(e)) return _RangePosition.middle;
386
+ return _RangePosition.none;
387
+ }
388
+
389
+ // ─────────────────────────────────────────────────────────────────────────────
390
+ // KasyDatePicker
391
+ // ─────────────────────────────────────────────────────────────────────────────
392
+
393
+ /// Date picker combining a text-field trigger with a floating calendar overlay.
394
+ ///
395
+ /// The trigger reuses [KasyTextField] in read-only mode so it inherits the
396
+ /// design-system input look (label, hint, suffix-icon slot, padding, border,
397
+ /// shadow). Any change made to [KasyTextField] (e.g. label or placeholder
398
+ /// styling) propagates automatically to this component. Tapping the field
399
+ /// opens a calendar below the trigger via [OverlayPortal] (popover mode),
400
+ /// inside a [showKasyDialog] (dialog mode) or [showKasyBottomSheet] (bottom
401
+ /// sheet mode). Selecting a day calls [onChanged] and closes the calendar.
402
+ ///
403
+ /// Usage:
404
+ /// ```dart
405
+ /// KasyDatePicker(
406
+ /// label: 'Date',
407
+ /// value: _date,
408
+ /// onChanged: (d) => setState(() => _date = d),
409
+ /// )
410
+ /// ```
411
+ class KasyDatePicker extends StatefulWidget {
412
+ const KasyDatePicker({
413
+ super.key,
414
+ this.label,
415
+ this.value,
416
+ this.onChanged,
417
+ this.range,
418
+ this.onRangeChanged,
419
+ this.selectionMode = KasyDateSelectionMode.single,
420
+ this.minDate,
421
+ this.maxDate,
422
+ this.enabled = true,
423
+ this.placeholder,
424
+ this.presentation = KasyDatePickerPresentation.popover,
425
+ this.showRequiredIndicator = false,
426
+ this.errorText,
427
+ this.isInvalid = false,
428
+ this.description,
429
+ this.dateFormat,
430
+ this.formatDate,
431
+ this.locale = KasyDatePickerLocale.en,
432
+ this.variant = KasyTextFieldVariant.primary,
433
+ this.focusBorder = true,
434
+ this.showSuffix = true,
435
+ this.showBarrier = true,
436
+ this.monthsToShow,
437
+ });
438
+
439
+ /// Label displayed above the field. Omit to hide.
440
+ final String? label;
441
+
442
+ /// Currently selected date — only used in [KasyDateSelectionMode.single].
443
+ /// Pass null for no selection.
444
+ final DateTime? value;
445
+
446
+ /// Called when the user picks a date in [KasyDateSelectionMode.single].
447
+ final ValueChanged<DateTime>? onChanged;
448
+
449
+ /// Currently selected start/end pair — only used in
450
+ /// [KasyDateSelectionMode.range]. Pass null for no selection. The picker
451
+ /// keeps the calendar open while the range is still being built, then
452
+ /// closes once both endpoints are set.
453
+ final KasyDateRange? range;
454
+
455
+ /// Called when the user updates the range. Fires twice: once with only
456
+ /// [KasyDateRange.start] set (after the first tap), then again with both
457
+ /// endpoints (after the second tap, just before the popover closes).
458
+ final ValueChanged<KasyDateRange>? onRangeChanged;
459
+
460
+ /// Whether the picker selects a single date or a start/end range.
461
+ /// Defaults to [KasyDateSelectionMode.single].
462
+ final KasyDateSelectionMode selectionMode;
463
+
464
+ /// Earliest selectable date (inclusive).
465
+ final DateTime? minDate;
466
+
467
+ /// Latest selectable date (inclusive).
468
+ final DateTime? maxDate;
469
+
470
+ final bool enabled;
471
+
472
+ /// Placeholder text shown when no date is selected. When null, the
473
+ /// placeholder is derived from the active [KasyDateFormat] preset
474
+ /// (e.g. "mm / dd / yyyy" for [KasyDateFormat.us]).
475
+ final String? placeholder;
476
+
477
+ /// How the calendar is presented when the field is tapped.
478
+ final KasyDatePickerPresentation presentation;
479
+
480
+ /// When true, shows a red `*` after the label.
481
+ final bool showRequiredIndicator;
482
+
483
+ /// Error message displayed below the field. Also triggers red border.
484
+ final String? errorText;
485
+
486
+ /// When true, shows a red border without an error message.
487
+ final bool isInvalid;
488
+
489
+ /// Helper text displayed below the field in muted color.
490
+ final String? description;
491
+
492
+ /// Built-in regional format preset. When null (default), falls back to
493
+ /// [KasyDatePickerLocale.defaultDateFormat] so the date follows the locale's
494
+ /// regional convention. Ignored when [formatDate] is provided.
495
+ final KasyDateFormat? dateFormat;
496
+
497
+ /// Fully custom date formatter. When provided, overrides [dateFormat] and
498
+ /// the locale default.
499
+ final String Function(DateTime)? formatDate;
500
+
501
+ /// Calendar display locale — controls month names, weekday labels, week
502
+ /// start day, and the regional [defaultDateFormat]. Defaults to
503
+ /// [KasyDatePickerLocale.en] (English, Sunday-first, US date format).
504
+ final KasyDatePickerLocale locale;
505
+
506
+ /// Visual variant of the trigger field. Defaults to
507
+ /// [KasyTextFieldVariant.primary] (white surface + soft shadow on mobile).
508
+ /// Use [KasyTextFieldVariant.secondary] for a flat filled look without
509
+ /// shadow, matching the "filled" KasyTextField style.
510
+ final KasyTextFieldVariant variant;
511
+
512
+ /// When true (default), the trigger field shows a focus border while the
513
+ /// calendar is open — mirroring the [KasyTextField] focus affordance so
514
+ /// the user clearly sees the field is "active". Set to false to suppress
515
+ /// the focus border in every state.
516
+ final bool focusBorder;
517
+
518
+ /// When true (default), the calendar icon is rendered in the trigger's
519
+ /// suffix slot. Set to false to hide the icon (useful for very compact
520
+ /// layouts where the field width is the only affordance).
521
+ final bool showSuffix;
522
+
523
+ /// Popover only: when true (default), a dimmed scrim is shown behind the
524
+ /// calendar (matching the dialog/bottom-sheet barriers). Set to false for
525
+ /// a lighter, "tooltip"-style overlay with no backdrop — the page stays
526
+ /// visible and tap-outside still closes the popover.
527
+ final bool showBarrier;
528
+
529
+ /// Number of calendar months shown side by side. Defaults to a single
530
+ /// month on every platform — pass an explicit 2 or 3 to opt in to the
531
+ /// multi-month layout (typical on wide web canvases).
532
+ ///
533
+ /// The nav-row layout follows automatically: a single-month picker uses
534
+ /// the compact "month/year left, arrows clustered right" style; a
535
+ /// multi-month picker uses "one arrow per edge, captions centered above
536
+ /// each grid". Callers don't choose the nav style explicitly.
537
+ final int? monthsToShow;
538
+
539
+ @override
540
+ State<KasyDatePicker> createState() => _KasyDatePickerState();
541
+ }
542
+
543
+ class _KasyDatePickerState extends State<KasyDatePicker>
544
+ with SingleTickerProviderStateMixin {
545
+ final LayerLink _layerLink = LayerLink();
546
+ final OverlayPortalController _portalController = OverlayPortalController();
547
+
548
+ // Unique ID so TapRegion knows "field + calendar" form one group.
549
+ final Object _tapGroupId = Object();
550
+
551
+ // Key on the trigger field — used to measure its width when opening the popover.
552
+ final GlobalKey _fieldKey = GlobalKey();
553
+
554
+ // Focus node owned by the trigger field. We want focus so the
555
+ // KasyTextField paints its focus border while the calendar is open — but
556
+ // the underlying TextField runs in readOnly mode, so no soft keyboard
557
+ // appears even when focused.
558
+ final FocusNode _fieldFocusNode = FocusNode();
559
+
560
+ // Controller backing the trigger KasyTextField — text mirrors the formatted
561
+ // date (or stays empty so the hint renders).
562
+ late final TextEditingController _displayController;
563
+
564
+ // Month currently displayed in the calendar (always first of the month).
565
+ late DateTime _viewMonth;
566
+
567
+ bool _isOpen = false;
568
+
569
+ // Selection picked inside the popover but not yet committed to the parent.
570
+ // We keep it parked while the close animation runs so the cell does NOT
571
+ // flip to its primary state mid-fade — the press feedback stays visible on
572
+ // the original (non-selected) cell, matching the dialog/bottom sheet UX.
573
+ // The actual commit happens at the end of the fade-out, so no artificial
574
+ // delay is introduced (we just piggy-back on the existing 180ms animation).
575
+ DateTime? _pendingPopoverSelection;
576
+
577
+ /// Same trick as [_pendingPopoverSelection] but for range-mode completion:
578
+ /// we park the completed range and commit it only after the fade-out so the
579
+ /// press feedback stays visible on the second-tap cell during the close.
580
+ KasyDateRange? _pendingPopoverRange;
581
+
582
+ // Width of the trigger field, read at open time.
583
+ double _calendarWidth = 288;
584
+
585
+ // When true, the popover renders ABOVE the trigger instead of below.
586
+ // Computed at open time based on available space.
587
+ bool _openUp = false;
588
+
589
+ // Conservative estimate of the calendar panel height. Kept on the high side
590
+ // (real layout measures ~460px) so the flip-up logic in _open() errs on the
591
+ // safe side — if there's any doubt, prefer opening above to avoid being
592
+ // clipped by the bottom of the screen.
593
+ static const double _kEstimatedCalendarHeight = 460;
594
+
595
+ late final AnimationController _animCtrl;
596
+ late final Animation<double> _fadeAnim;
597
+ late final Animation<double> _scaleAnim;
598
+
599
+ @override
600
+ void initState() {
601
+ super.initState();
602
+ _viewMonth = _monthOf(_initialAnchorDate() ?? DateTime.now());
603
+ _displayController = TextEditingController(text: _displayText());
604
+ _animCtrl = AnimationController(
605
+ vsync: this,
606
+ duration: const Duration(milliseconds: 180),
607
+ );
608
+ _fadeAnim = CurvedAnimation(
609
+ parent: _animCtrl,
610
+ curve: Curves.easeOut,
611
+ reverseCurve: Curves.easeIn,
612
+ );
613
+ _scaleAnim = Tween<double>(begin: 0.95, end: 1.0).animate(
614
+ CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut),
615
+ );
616
+ }
617
+
618
+ @override
619
+ void didUpdateWidget(KasyDatePicker old) {
620
+ super.didUpdateWidget(old);
621
+ final bool selectionChanged = old.value != widget.value ||
622
+ old.range != widget.range ||
623
+ old.selectionMode != widget.selectionMode;
624
+ // Re-format the displayed text whenever the formatting inputs change too,
625
+ // not just the value — otherwise switching dateFormat/formatDate/locale
626
+ // leaves the field showing the previous format's string.
627
+ final bool formatChanged = old.dateFormat != widget.dateFormat ||
628
+ old.formatDate != widget.formatDate ||
629
+ old.locale != widget.locale;
630
+ if (!selectionChanged && !formatChanged) return;
631
+ if (selectionChanged) {
632
+ final DateTime? anchor = _initialAnchorDate();
633
+ if (anchor != null) _viewMonth = _monthOf(anchor);
634
+ }
635
+ _displayController.text = _displayText();
636
+ }
637
+
638
+ @override
639
+ void dispose() {
640
+ _displayController.dispose();
641
+ _fieldFocusNode.dispose();
642
+ _animCtrl.dispose();
643
+ super.dispose();
644
+ }
645
+
646
+ DateTime _monthOf(DateTime d) => DateTime(d.year, d.month);
647
+
648
+ /// Resolved formatting preset: respects an explicit override on the widget
649
+ /// and falls back to the locale's regional default.
650
+ KasyDateFormat get _resolvedFormat =>
651
+ widget.dateFormat ?? widget.locale.defaultDateFormat;
652
+
653
+ /// Unified selection. In single mode the picked [value] is wrapped in a
654
+ /// range with `start == end == value` so the calendar tree only deals
655
+ /// with one shape ([KasyDateRange]) regardless of selectionMode.
656
+ KasyDateRange? get _effectiveRange {
657
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
658
+ return widget.range;
659
+ }
660
+ if (widget.value == null) return null;
661
+ return KasyDateRange(start: widget.value, end: widget.value);
662
+ }
663
+
664
+ String _formatDate(DateTime date) =>
665
+ widget.formatDate?.call(date) ?? _formatWithPreset(date, _resolvedFormat);
666
+
667
+ /// Picks the most relevant date for opening the calendar view: the single
668
+ /// value if present, otherwise the range start (range mode), otherwise
669
+ /// null (the calendar then anchors on today).
670
+ DateTime? _initialAnchorDate() {
671
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
672
+ return widget.range?.start;
673
+ }
674
+ return widget.value;
675
+ }
676
+
677
+ /// Text shown in the trigger field. Single mode renders the picked date;
678
+ /// range mode renders "start - end" once both endpoints are set, or just
679
+ /// "start - ..." while waiting for the end so the user sees progress.
680
+ String _displayText() {
681
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
682
+ final KasyDateRange? r = widget.range;
683
+ if (r == null || r.start == null) return '';
684
+ final String startStr = _formatDate(r.start!);
685
+ if (r.end == null) return '$startStr - ...';
686
+ return '$startStr - ${_formatDate(r.end!)}';
687
+ }
688
+ if (widget.value == null) return '';
689
+ return _formatDate(widget.value!);
690
+ }
691
+
692
+ /// Placeholder shown when no date is picked. Uses the explicit
693
+ /// [KasyDatePicker.placeholder] when provided, otherwise derives the hint
694
+ /// from the active preset so it matches what the user will eventually see.
695
+ String get _resolvedPlaceholder =>
696
+ widget.placeholder ?? _placeholderFor(_resolvedFormat);
697
+
698
+ // ── Calendar toggle ────────────────────────────────────────────────────────
699
+
700
+ void _toggleCalendar() {
701
+ if (!widget.enabled) return;
702
+ if (widget.presentation == KasyDatePickerPresentation.popover) {
703
+ if (_isOpen) {
704
+ _close();
705
+ } else {
706
+ _open();
707
+ }
708
+ } else if (widget.presentation == KasyDatePickerPresentation.dialog) {
709
+ _openDialog();
710
+ } else {
711
+ _openBottomSheet();
712
+ }
713
+ }
714
+
715
+ void _open() {
716
+ // Read the actual field width and global position so the calendar matches
717
+ // it and we can decide whether to open above or below the trigger.
718
+ final RenderBox? fieldBox =
719
+ _fieldKey.currentContext?.findRenderObject() as RenderBox?;
720
+ _calendarWidth = fieldBox?.size.width ?? 288;
721
+
722
+ bool openUp = false;
723
+ if (fieldBox != null) {
724
+ final Offset fieldTopLeft = fieldBox.localToGlobal(Offset.zero);
725
+ final Size viewport = MediaQuery.sizeOf(context);
726
+ // viewPaddingOf returns the system safe-area insets even when an
727
+ // ancestor SafeArea has already consumed them — paddingOf would
728
+ // return 0 in that case and overestimate the available room.
729
+ final EdgeInsets safe = MediaQuery.viewPaddingOf(context);
730
+ final double fieldBottom = fieldTopLeft.dy + fieldBox.size.height;
731
+ final double spaceBelow = viewport.height - safe.bottom - fieldBottom;
732
+ final double spaceAbove = fieldTopLeft.dy - safe.top;
733
+ // Flip up only when below doesn't fit AND above fits better.
734
+ if (spaceBelow < _kEstimatedCalendarHeight + 8 &&
735
+ spaceAbove > spaceBelow) {
736
+ openUp = true;
737
+ }
738
+ }
739
+ _openUp = openUp;
740
+
741
+ _portalController.show();
742
+ _animCtrl.forward();
743
+ // Focus so the trigger field paints its focus border while the calendar
744
+ // is open (only when focusBorder is enabled — otherwise we skip to avoid
745
+ // the field announcing focus to assistive tech for no visual reason).
746
+ if (widget.focusBorder) _fieldFocusNode.requestFocus();
747
+ setState(() => _isOpen = true);
748
+ }
749
+
750
+ void _close() {
751
+ _animCtrl.reverse().then((_) {
752
+ if (!mounted) return;
753
+ _portalController.hide();
754
+ // Commit any selection picked inside the popover only after the fade-out
755
+ // completes. This keeps the press feedback on the original cell during
756
+ // the close, instead of flashing the cell to primary mid-animation.
757
+ final DateTime? pendingSingle = _pendingPopoverSelection;
758
+ if (pendingSingle != null) {
759
+ _pendingPopoverSelection = null;
760
+ widget.onChanged?.call(pendingSingle);
761
+ }
762
+ final KasyDateRange? pendingRange = _pendingPopoverRange;
763
+ if (pendingRange != null) {
764
+ _pendingPopoverRange = null;
765
+ widget.onRangeChanged?.call(pendingRange);
766
+ }
767
+ _fieldFocusNode.unfocus();
768
+ });
769
+ setState(() => _isOpen = false);
770
+ }
771
+
772
+ /// Unified entry point for a day tap. Branches on selectionMode:
773
+ /// - single: commit immediately (or defer to fade-out in popover mode).
774
+ /// - range: build the start/end pair incrementally; only close after the
775
+ /// end is picked. A tap earlier than the current start restarts the
776
+ /// range with that earlier date as the new start.
777
+ void _selectDate(DateTime date) {
778
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
779
+ _handleRangeSelection(date);
780
+ return;
781
+ }
782
+ if (widget.presentation == KasyDatePickerPresentation.popover) {
783
+ // Park the selection and start the close animation immediately — no
784
+ // artificial delay. The actual onChanged commit fires from _close()
785
+ // when the existing fade-out finishes.
786
+ _pendingPopoverSelection = date;
787
+ _close();
788
+ return;
789
+ }
790
+ widget.onChanged?.call(date);
791
+ _closeOverlayRoute();
792
+ }
793
+
794
+ void _handleRangeSelection(DateTime date) {
795
+ final KasyDateRange current = widget.range ?? const KasyDateRange();
796
+ final bool isFirstPick = current.start == null ||
797
+ current.isComplete ||
798
+ date.isBefore(current.start!);
799
+ if (isFirstPick) {
800
+ // Start (or restart) the range. Calendar stays open waiting for end.
801
+ widget.onRangeChanged?.call(KasyDateRange(start: date));
802
+ return;
803
+ }
804
+ // Closing tap — the user just picked the end. Commit + close.
805
+ final KasyDateRange completed =
806
+ KasyDateRange(start: current.start, end: date);
807
+ if (widget.presentation == KasyDatePickerPresentation.popover) {
808
+ _pendingPopoverRange = completed;
809
+ _close();
810
+ return;
811
+ }
812
+ widget.onRangeChanged?.call(completed);
813
+ _closeOverlayRoute();
814
+ }
815
+
816
+ /// Pops the dialog/bottom sheet route opened by this picker. No-op for
817
+ /// popover mode (which closes via [_close]).
818
+ void _closeOverlayRoute() {
819
+ if (widget.presentation == KasyDatePickerPresentation.popover) return;
820
+ Navigator.of(context, rootNavigator: true).maybePop();
821
+ }
822
+
823
+ void _openDialog() {
824
+ // Local copies of the calendar's mutable state. The dialog lives on a
825
+ // separate route, so setState in the parent does NOT rebuild it — we
826
+ // mirror viewMonth AND range here and refresh via setDialogState so the
827
+ // user sees their picks immediately (mirrors the popover/bottom sheet
828
+ // behavior in range mode, where the first tap must paint the start
829
+ // endpoint right away).
830
+ DateTime dialogViewMonth = _viewMonth;
831
+ KasyDateRange? dialogRange = _effectiveRange;
832
+ if (widget.focusBorder) _fieldFocusNode.requestFocus();
833
+
834
+ showKasyDialog<void>(
835
+ context: context,
836
+ builder: (ctx) => StatefulBuilder(
837
+ builder: (ctx, setDialogState) {
838
+ // Handles taps inside the dialog. Mirrors [_handleRangeSelection]
839
+ // and [_selectDate] but updates dialogRange via setDialogState so
840
+ // the visual selection updates immediately while still notifying
841
+ // the parent via onChanged/onRangeChanged.
842
+ void onDateTapped(DateTime date) {
843
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
844
+ final KasyDateRange current =
845
+ dialogRange ?? const KasyDateRange();
846
+ final bool isFirstPick = current.start == null ||
847
+ current.isComplete ||
848
+ date.isBefore(current.start!);
849
+ if (isFirstPick) {
850
+ final KasyDateRange started = KasyDateRange(start: date);
851
+ setDialogState(() => dialogRange = started);
852
+ widget.onRangeChanged?.call(started);
853
+ return;
854
+ }
855
+ final KasyDateRange completed =
856
+ KasyDateRange(start: current.start, end: date);
857
+ setDialogState(() => dialogRange = completed);
858
+ widget.onRangeChanged?.call(completed);
859
+ Navigator.of(ctx).pop();
860
+ return;
861
+ }
862
+ widget.onChanged?.call(date);
863
+ Navigator.of(ctx).pop();
864
+ }
865
+
866
+ return _CalendarPanel(
867
+ // Omit calendarWidth so the panel stretches to the basic dialog's
868
+ // natural inset (showKasyDialog uses KasySpacing.lg horizontal
869
+ // insetPadding). Matches the "basic dialog" proportion.
870
+ viewMonth: dialogViewMonth,
871
+ range: dialogRange,
872
+ monthsToShow: widget.monthsToShow,
873
+ onDateSelected: onDateTapped,
874
+ onPreviousMonth: () {
875
+ setDialogState(() {
876
+ dialogViewMonth = DateTime(
877
+ dialogViewMonth.month == 1
878
+ ? dialogViewMonth.year - 1
879
+ : dialogViewMonth.year,
880
+ dialogViewMonth.month == 1 ? 12 : dialogViewMonth.month - 1,
881
+ );
882
+ });
883
+ },
884
+ onNextMonth: () {
885
+ setDialogState(() {
886
+ dialogViewMonth = DateTime(
887
+ dialogViewMonth.month == 12
888
+ ? dialogViewMonth.year + 1
889
+ : dialogViewMonth.year,
890
+ dialogViewMonth.month == 12 ? 1 : dialogViewMonth.month + 1,
891
+ );
892
+ });
893
+ },
894
+ onYearSelected: (year) {
895
+ setDialogState(() {
896
+ dialogViewMonth = DateTime(year, dialogViewMonth.month);
897
+ });
898
+ },
899
+ minDate: widget.minDate,
900
+ maxDate: widget.maxDate,
901
+ locale: widget.locale,
902
+ );
903
+ },
904
+ ),
905
+ ).whenComplete(() {
906
+ if (mounted) _fieldFocusNode.unfocus();
907
+ });
908
+ }
909
+
910
+ void _openBottomSheet() {
911
+ if (widget.focusBorder) _fieldFocusNode.requestFocus();
912
+ showKasyBottomSheet<void>(
913
+ context: context,
914
+ isScrollControlled: true,
915
+ builder: (_) => _BottomSheetContent(
916
+ initialViewMonth: _viewMonth,
917
+ initialRange: _effectiveRange,
918
+ selectionMode: widget.selectionMode,
919
+ onChanged: widget.onChanged,
920
+ onRangeChanged: widget.onRangeChanged,
921
+ minDate: widget.minDate,
922
+ maxDate: widget.maxDate,
923
+ locale: widget.locale,
924
+ monthsToShow: widget.monthsToShow,
925
+ ),
926
+ ).whenComplete(() {
927
+ if (mounted) _fieldFocusNode.unfocus();
928
+ });
929
+ }
930
+
931
+ void _previousMonth() {
932
+ setState(() {
933
+ _viewMonth = DateTime(
934
+ _viewMonth.month == 1 ? _viewMonth.year - 1 : _viewMonth.year,
935
+ _viewMonth.month == 1 ? 12 : _viewMonth.month - 1,
936
+ );
937
+ });
938
+ }
939
+
940
+ void _nextMonth() {
941
+ setState(() {
942
+ _viewMonth = DateTime(
943
+ _viewMonth.month == 12 ? _viewMonth.year + 1 : _viewMonth.year,
944
+ _viewMonth.month == 12 ? 1 : _viewMonth.month + 1,
945
+ );
946
+ });
947
+ }
948
+
949
+ // ── Build ──────────────────────────────────────────────────────────────────
950
+
951
+ @override
952
+ Widget build(BuildContext context) {
953
+ // For popover presentation, wrap with OverlayPortal.
954
+ // For dialog/bottomSheet, just the field — no overlay portal needed.
955
+ if (widget.presentation == KasyDatePickerPresentation.popover) {
956
+ return TapRegion(
957
+ groupId: _tapGroupId,
958
+ child: OverlayPortal(
959
+ controller: _portalController,
960
+ overlayChildBuilder: _buildOverlay,
961
+ child: _buildField(context),
962
+ ),
963
+ );
964
+ }
965
+
966
+ return _buildField(context);
967
+ }
968
+
969
+ // ── DateField trigger ──────────────────────────────────────────────────────
970
+ //
971
+ // Label and footer (description / errorText) are rendered HERE — not passed
972
+ // into KasyTextField — so the CompositedTransformTarget can wrap only the
973
+ // KasyTextField's field rectangle. This way the popover anchors to the
974
+ // input box itself, not to the full widget area (which would push the
975
+ // calendar below the helper text).
976
+ //
977
+ // Visual styling for label/footer mirrors KasyTextField's own rendering so
978
+ // the trigger keeps looking like any other input in the kit.
979
+
980
+ Widget _buildField(BuildContext context) {
981
+ final KasyColors c = context.colors;
982
+ final bool isDisabled = !widget.enabled;
983
+ final bool hasErrorText =
984
+ widget.errorText != null && widget.errorText!.isNotEmpty;
985
+ final bool hasInvalidState = widget.isInvalid || hasErrorText;
986
+ final String? footerText =
987
+ hasErrorText ? widget.errorText : widget.description;
988
+ final Color labelColor = hasInvalidState ? c.error : c.fieldLabel;
989
+
990
+ return Column(
991
+ crossAxisAlignment: CrossAxisAlignment.start,
992
+ mainAxisSize: MainAxisSize.min,
993
+ children: [
994
+ if (widget.label != null && widget.label!.trim().isNotEmpty) ...[
995
+ Padding(
996
+ padding: const EdgeInsets.only(left: KasySpacing.sm - 2),
997
+ child: Row(
998
+ mainAxisSize: MainAxisSize.min,
999
+ children: [
1000
+ Text(
1001
+ widget.label!,
1002
+ style: context.textTheme.bodyMedium?.copyWith(
1003
+ color: isDisabled
1004
+ ? labelColor.withValues(alpha: 0.46)
1005
+ : labelColor,
1006
+ fontWeight: FontWeight.w500,
1007
+ ),
1008
+ ),
1009
+ if (widget.showRequiredIndicator)
1010
+ Text(
1011
+ ' *',
1012
+ style: context.textTheme.bodyMedium?.copyWith(
1013
+ color: c.error,
1014
+ fontWeight: FontWeight.w500,
1015
+ ),
1016
+ ),
1017
+ ],
1018
+ ),
1019
+ ),
1020
+ const SizedBox(height: KasySpacing.xs),
1021
+ ],
1022
+ // Only this rectangle is the popover anchor — label and footer are
1023
+ // siblings, so they don't shift the calendar's open position.
1024
+ CompositedTransformTarget(
1025
+ link: _layerLink,
1026
+ child: MouseRegion(
1027
+ // The trigger looks like a text input but BEHAVES like a button —
1028
+ // override the I-beam cursor that the inner [TextField] would
1029
+ // otherwise show on web/desktop. The IgnorePointer below already
1030
+ // blocks taps from reaching the field, so the GestureDetector
1031
+ // wrapping us owns the click.
1032
+ cursor: widget.enabled
1033
+ ? SystemMouseCursors.click
1034
+ : SystemMouseCursors.basic,
1035
+ child: GestureDetector(
1036
+ behavior: HitTestBehavior.opaque,
1037
+ onTap: _toggleCalendar,
1038
+ child: IgnorePointer(
1039
+ child: KasyTextField(
1040
+ key: _fieldKey,
1041
+ controller: _displayController,
1042
+ focusNode: _fieldFocusNode,
1043
+ readOnly: true,
1044
+ enabled: widget.enabled,
1045
+ hint: _resolvedPlaceholder,
1046
+ isInvalid: hasInvalidState,
1047
+ variant: widget.variant,
1048
+ focusBorder: widget.focusBorder,
1049
+ // No caret, no selection handles, no "blue text" when the
1050
+ // trigger is focused while the calendar is open — keeps the
1051
+ // field reading as a button, not an editable input.
1052
+ enableInteractiveSelection: false,
1053
+ suffix:
1054
+ widget.showSuffix ? const Icon(KasyIcons.calendar) : null,
1055
+ ),
1056
+ ),
1057
+ ),
1058
+ ),
1059
+ ),
1060
+ if (footerText != null) ...[
1061
+ const SizedBox(height: KasySpacing.xs),
1062
+ Padding(
1063
+ padding: const EdgeInsets.symmetric(
1064
+ horizontal: KasySpacing.sm - 2,
1065
+ ),
1066
+ child: Text(
1067
+ footerText,
1068
+ style: context.textTheme.bodySmall?.copyWith(
1069
+ color: (hasErrorText ? c.error : c.muted).withValues(
1070
+ // Match KasyTextField's disabled description fade (0.34)
1071
+ // so the helper text dims along with the field.
1072
+ alpha: isDisabled ? 0.34 : 1,
1073
+ ),
1074
+ ),
1075
+ ),
1076
+ ),
1077
+ ],
1078
+ ],
1079
+ );
1080
+ }
1081
+
1082
+ // ── Calendar overlay (popover only) ───────────────────────────────────────
1083
+
1084
+ Widget _buildOverlay(BuildContext context) {
1085
+ // Same barrier tone as KasyDialog so the popover backdrop matches the
1086
+ // dialog/bottom-sheet behavior (the field "lifts" above a dimmed scrim).
1087
+ final bool dark = Theme.of(context).brightness == Brightness.dark;
1088
+ final Color barrierColor = Colors.black.withValues(
1089
+ alpha: dark ? 0.58 : 0.42,
1090
+ );
1091
+
1092
+ return TapRegion(
1093
+ groupId: _tapGroupId,
1094
+ onTapOutside: (_) => _close(),
1095
+ child: Stack(
1096
+ children: [
1097
+ // Dimmed backdrop — fades in/out with the calendar. Suppressed when
1098
+ // [showBarrier] is false so the popover floats over the page like a
1099
+ // tooltip; tap-outside still closes via the surrounding TapRegion.
1100
+ if (widget.showBarrier)
1101
+ Positioned.fill(
1102
+ child: FadeTransition(
1103
+ opacity: _fadeAnim,
1104
+ child: GestureDetector(
1105
+ behavior: HitTestBehavior.opaque,
1106
+ onTap: _close,
1107
+ child: ColoredBox(color: barrierColor),
1108
+ ),
1109
+ ),
1110
+ ),
1111
+ // Smart positioning: anchor the panel BELOW the field by default,
1112
+ // or ABOVE it when there isn't enough room below (see _open()).
1113
+ CompositedTransformFollower(
1114
+ link: _layerLink,
1115
+ targetAnchor:
1116
+ _openUp ? Alignment.topLeft : Alignment.bottomLeft,
1117
+ followerAnchor:
1118
+ _openUp ? Alignment.bottomLeft : Alignment.topLeft,
1119
+ // 8px gap between trigger edge and calendar — per Figma spec.
1120
+ offset: _openUp ? const Offset(0, -8) : const Offset(0, 8),
1121
+ child: FadeTransition(
1122
+ opacity: _fadeAnim,
1123
+ child: ScaleTransition(
1124
+ scale: _scaleAnim,
1125
+ // Grow from the edge closest to the trigger so the open
1126
+ // animation reads correctly in both directions.
1127
+ alignment:
1128
+ _openUp ? Alignment.bottomCenter : Alignment.topCenter,
1129
+ child: _CalendarPanel(
1130
+ calendarWidth: _calendarWidth,
1131
+ viewMonth: _viewMonth,
1132
+ range: _effectiveRange,
1133
+ monthsToShow: widget.monthsToShow,
1134
+ onDateSelected: _selectDate,
1135
+ onPreviousMonth: _previousMonth,
1136
+ onNextMonth: _nextMonth,
1137
+ onYearSelected: (year) => setState(() {
1138
+ _viewMonth = DateTime(year, _viewMonth.month);
1139
+ }),
1140
+ minDate: widget.minDate,
1141
+ maxDate: widget.maxDate,
1142
+ locale: widget.locale,
1143
+ ),
1144
+ ),
1145
+ ),
1146
+ ),
1147
+ ],
1148
+ ),
1149
+ );
1150
+ }
1151
+ }
1152
+
1153
+ // ─────────────────────────────────────────────────────────────────────────────
1154
+ // _BottomSheetContent — KasyBottomSheet-style wrapper for the calendar
1155
+ // ─────────────────────────────────────────────────────────────────────────────
1156
+
1157
+ class _BottomSheetContent extends StatefulWidget {
1158
+ const _BottomSheetContent({
1159
+ required this.initialViewMonth,
1160
+ required this.initialRange,
1161
+ required this.selectionMode,
1162
+ this.onChanged,
1163
+ this.onRangeChanged,
1164
+ this.minDate,
1165
+ this.maxDate,
1166
+ this.locale = KasyDatePickerLocale.en,
1167
+ this.monthsToShow,
1168
+ });
1169
+
1170
+ final DateTime initialViewMonth;
1171
+
1172
+ /// Initial selection — used as a seed for the local state inside the sheet
1173
+ /// (subsequent taps update that state and notify the parent through the
1174
+ /// callbacks below).
1175
+ final KasyDateRange? initialRange;
1176
+
1177
+ final KasyDateSelectionMode selectionMode;
1178
+ final ValueChanged<DateTime>? onChanged;
1179
+ final ValueChanged<KasyDateRange>? onRangeChanged;
1180
+ final DateTime? minDate;
1181
+ final DateTime? maxDate;
1182
+ final KasyDatePickerLocale locale;
1183
+ final int? monthsToShow;
1184
+
1185
+ @override
1186
+ State<_BottomSheetContent> createState() => _BottomSheetContentState();
1187
+ }
1188
+
1189
+ class _BottomSheetContentState extends State<_BottomSheetContent> {
1190
+ late DateTime _viewMonth;
1191
+
1192
+ KasyDateRange? _range;
1193
+
1194
+ @override
1195
+ void initState() {
1196
+ super.initState();
1197
+ _viewMonth = widget.initialViewMonth;
1198
+ _range = widget.initialRange;
1199
+ }
1200
+
1201
+ void _previousMonth() {
1202
+ setState(() {
1203
+ _viewMonth = DateTime(
1204
+ _viewMonth.month == 1 ? _viewMonth.year - 1 : _viewMonth.year,
1205
+ _viewMonth.month == 1 ? 12 : _viewMonth.month - 1,
1206
+ );
1207
+ });
1208
+ }
1209
+
1210
+ void _nextMonth() {
1211
+ setState(() {
1212
+ _viewMonth = DateTime(
1213
+ _viewMonth.month == 12 ? _viewMonth.year + 1 : _viewMonth.year,
1214
+ _viewMonth.month == 12 ? 1 : _viewMonth.month + 1,
1215
+ );
1216
+ });
1217
+ }
1218
+
1219
+ /// Handles a day tap inside the sheet. In range mode, paints the start
1220
+ /// endpoint immediately on the first tap (so the user gets feedback), and
1221
+ /// closes the sheet only after the end is picked. Single mode closes on
1222
+ /// the first tap.
1223
+ void _onDateTapped(DateTime date) {
1224
+ if (widget.selectionMode == KasyDateSelectionMode.range) {
1225
+ final KasyDateRange current = _range ?? const KasyDateRange();
1226
+ final bool isFirstPick = current.start == null ||
1227
+ current.isComplete ||
1228
+ date.isBefore(current.start!);
1229
+ if (isFirstPick) {
1230
+ final KasyDateRange started = KasyDateRange(start: date);
1231
+ setState(() => _range = started);
1232
+ widget.onRangeChanged?.call(started);
1233
+ return;
1234
+ }
1235
+ final KasyDateRange completed =
1236
+ KasyDateRange(start: current.start, end: date);
1237
+ setState(() => _range = completed);
1238
+ widget.onRangeChanged?.call(completed);
1239
+ Navigator.of(context).pop();
1240
+ return;
1241
+ }
1242
+ widget.onChanged?.call(date);
1243
+ Navigator.of(context).pop();
1244
+ }
1245
+
1246
+ @override
1247
+ Widget build(BuildContext context) {
1248
+ final KasyColors c = context.colors;
1249
+ final double bottomInset = MediaQuery.paddingOf(context).bottom;
1250
+
1251
+ return Material(
1252
+ color: c.surface,
1253
+ borderRadius: const BorderRadius.vertical(
1254
+ top: Radius.circular(KasyRadius.xl),
1255
+ ),
1256
+ child: Column(
1257
+ mainAxisSize: MainAxisSize.min,
1258
+ children: [
1259
+ // Drag handle — matches KasyBottomSheet._DragHandle exactly.
1260
+ Padding(
1261
+ padding: const EdgeInsets.only(top: KasySpacing.smd),
1262
+ child: Center(
1263
+ child: Container(
1264
+ width: 36,
1265
+ height: 4,
1266
+ decoration: BoxDecoration(
1267
+ color: c.outline.withValues(alpha: 0.35),
1268
+ borderRadius: BorderRadius.circular(KasyRadius.full),
1269
+ ),
1270
+ ),
1271
+ ),
1272
+ ),
1273
+ // Calendar content — no card decoration (sheet surface is already white).
1274
+ Padding(
1275
+ padding: EdgeInsets.only(
1276
+ left: KasySpacing.md,
1277
+ right: KasySpacing.md,
1278
+ top: KasySpacing.sm,
1279
+ bottom: KasySpacing.md + bottomInset,
1280
+ ),
1281
+ child: _CalendarPanel(
1282
+ bare: true,
1283
+ viewMonth: _viewMonth,
1284
+ range: _range,
1285
+ monthsToShow: widget.monthsToShow,
1286
+ onDateSelected: _onDateTapped,
1287
+ onPreviousMonth: _previousMonth,
1288
+ onNextMonth: _nextMonth,
1289
+ onYearSelected: (year) => setState(() {
1290
+ _viewMonth = DateTime(year, _viewMonth.month);
1291
+ }),
1292
+ minDate: widget.minDate,
1293
+ maxDate: widget.maxDate,
1294
+ locale: widget.locale,
1295
+ ),
1296
+ ),
1297
+ ],
1298
+ ),
1299
+ );
1300
+ }
1301
+ }
1302
+
1303
+ // ─────────────────────────────────────────────────────────────────────────────
1304
+ // _CalendarPanel — now StatefulWidget to manage view mode (month / year)
1305
+ // ─────────────────────────────────────────────────────────────────────────────
1306
+
1307
+ class _CalendarPanel extends StatefulWidget {
1308
+ const _CalendarPanel({
1309
+ required this.viewMonth,
1310
+ required this.range,
1311
+ required this.onDateSelected,
1312
+ required this.onPreviousMonth,
1313
+ required this.onNextMonth,
1314
+ required this.onYearSelected,
1315
+ this.calendarWidth,
1316
+ this.minDate,
1317
+ this.maxDate,
1318
+ this.bare = false,
1319
+ this.locale = KasyDatePickerLocale.en,
1320
+ this.monthsToShow,
1321
+ });
1322
+
1323
+ /// Number of months to render side-by-side. Defaults to 1 — multi-month
1324
+ /// is opt-in via [KasyDatePicker.monthsToShow].
1325
+ final int? monthsToShow;
1326
+
1327
+ final DateTime viewMonth;
1328
+
1329
+ /// Current selection. In single-date mode, the parent wraps the picked
1330
+ /// value in a [KasyDateRange] with `start == end` so the same widget tree
1331
+ /// renders both modes (single = single circle; range = start + bar + end).
1332
+ final KasyDateRange? range;
1333
+ final ValueChanged<DateTime> onDateSelected;
1334
+ final VoidCallback onPreviousMonth;
1335
+ final VoidCallback onNextMonth;
1336
+
1337
+ /// Called when the user selects a year in year-picker mode.
1338
+ final ValueChanged<int> onYearSelected;
1339
+
1340
+ /// Width of the panel. Defaults to [double.infinity] when null.
1341
+ final double? calendarWidth;
1342
+ final DateTime? minDate;
1343
+ final DateTime? maxDate;
1344
+
1345
+ /// When true, skips the card decoration (no Material wrapper, no
1346
+ /// background, no shadow). Use when the parent (e.g. bottom sheet)
1347
+ /// already provides the surface background.
1348
+ final bool bare;
1349
+
1350
+ /// Calendar display locale — month names, weekday labels, week start day.
1351
+ final KasyDatePickerLocale locale;
1352
+
1353
+ @override
1354
+ State<_CalendarPanel> createState() => _CalendarPanelState();
1355
+ }
1356
+
1357
+ class _CalendarPanelState extends State<_CalendarPanel> {
1358
+ _CalendarViewMode _viewMode = _CalendarViewMode.month;
1359
+
1360
+ /// Resolves the actual number of months to render side by side. Defaults
1361
+ /// to a single month on every platform; the multi-month layout is opt-in
1362
+ /// via [KasyDatePicker.monthsToShow] (1, 2 or 3).
1363
+ int _resolvedMonthCount() =>
1364
+ widget.monthsToShow?.clamp(1, 3) ?? 1;
1365
+
1366
+ Widget _buildContent() {
1367
+ if (_viewMode == _CalendarViewMode.year) {
1368
+ return Column(
1369
+ mainAxisSize: MainAxisSize.min,
1370
+ children: [
1371
+ _CalendarNavRow(
1372
+ viewMonth: widget.viewMonth,
1373
+ viewMode: _viewMode,
1374
+ onToggleMode: _toggleMode,
1375
+ // Arrows disabled in year mode — user picks via the scroll list.
1376
+ onPrevious: null,
1377
+ onNext: null,
1378
+ locale: widget.locale,
1379
+ // Style follows the layout: single-month uses the compact iOS-ish
1380
+ // style (label left, arrows clustered right); multi-month uses
1381
+ // the edge-arrows layout (rendered below as a custom row), so a
1382
+ // single-month panel always gets KasyDatePickerNavStyle.single.
1383
+ style: KasyDatePickerNavStyle.single,
1384
+ ),
1385
+ _YearPickerContent(
1386
+ currentYear: widget.viewMonth.year,
1387
+ onYearSelected: (year) {
1388
+ widget.onYearSelected(year);
1389
+ setState(() => _viewMode = _CalendarViewMode.month);
1390
+ },
1391
+ ),
1392
+ ],
1393
+ );
1394
+ }
1395
+
1396
+ final int months = _resolvedMonthCount();
1397
+
1398
+ return Builder(
1399
+ builder: (context) {
1400
+ if (months == 1) {
1401
+ return Column(
1402
+ mainAxisSize: MainAxisSize.min,
1403
+ children: [
1404
+ _CalendarNavRow(
1405
+ viewMonth: widget.viewMonth,
1406
+ viewMode: _viewMode,
1407
+ onToggleMode: _toggleMode,
1408
+ onPrevious: widget.onPreviousMonth,
1409
+ onNext: widget.onNextMonth,
1410
+ locale: widget.locale,
1411
+ // Style follows the layout: single-month uses the compact iOS-ish
1412
+ // style (label left, arrows clustered right); multi-month uses
1413
+ // the edge-arrows layout (rendered below as a custom row), so a
1414
+ // single-month panel always gets KasyDatePickerNavStyle.single.
1415
+ style: KasyDatePickerNavStyle.single,
1416
+ ),
1417
+ _MonthGrid(
1418
+ viewMonth: widget.viewMonth,
1419
+ range: widget.range,
1420
+ onDateSelected: widget.onDateSelected,
1421
+ minDate: widget.minDate,
1422
+ maxDate: widget.maxDate,
1423
+ locale: widget.locale,
1424
+ // Single-month layout already has the title in the nav row
1425
+ // above, so the grid omits its own header.
1426
+ showHeader: false,
1427
+ ),
1428
+ ],
1429
+ );
1430
+ }
1431
+
1432
+ // Multi-month layout: arrows live on the outer edges, the month
1433
+ // captions sit between them on the same row, and the grids align
1434
+ // perfectly below their captions because both rows share the same
1435
+ // column proportions (an arrow-wide SizedBox + Expanded grids +
1436
+ // arrow-wide SizedBox).
1437
+ const double navArrowWidth = 28;
1438
+ final TextStyle? headerStyle =
1439
+ context.textTheme.labelLarge?.copyWith(
1440
+ color: context.colors.onSurface,
1441
+ fontWeight: FontWeight.w700,
1442
+ fontSize: 14,
1443
+ );
1444
+
1445
+ return Column(
1446
+ mainAxisSize: MainAxisSize.min,
1447
+ children: [
1448
+ SizedBox(
1449
+ height: 56,
1450
+ child: Row(
1451
+ children: [
1452
+ _NavArrowButton(
1453
+ icon: KasyIcons.arrowBackIos,
1454
+ onTap: widget.onPreviousMonth,
1455
+ ),
1456
+ for (int i = 0; i < months; i++) ...[
1457
+ if (i > 0) const SizedBox(width: KasySpacing.lg),
1458
+ Expanded(
1459
+ child: Center(
1460
+ child: Text(
1461
+ _monthYearLabel(
1462
+ _shiftMonth(widget.viewMonth, i),
1463
+ widget.locale,
1464
+ ),
1465
+ style: headerStyle,
1466
+ ),
1467
+ ),
1468
+ ),
1469
+ ],
1470
+ _NavArrowButton(
1471
+ icon: KasyIcons.arrowForwardIos,
1472
+ onTap: widget.onNextMonth,
1473
+ ),
1474
+ ],
1475
+ ),
1476
+ ),
1477
+ Row(
1478
+ crossAxisAlignment: CrossAxisAlignment.start,
1479
+ children: [
1480
+ const SizedBox(width: navArrowWidth),
1481
+ for (int i = 0; i < months; i++) ...[
1482
+ if (i > 0) const SizedBox(width: KasySpacing.lg),
1483
+ Expanded(
1484
+ child: _MonthGrid(
1485
+ viewMonth: _shiftMonth(widget.viewMonth, i),
1486
+ range: widget.range,
1487
+ onDateSelected: widget.onDateSelected,
1488
+ minDate: widget.minDate,
1489
+ maxDate: widget.maxDate,
1490
+ locale: widget.locale,
1491
+ // Header lives on the row above, between the arrows.
1492
+ showHeader: false,
1493
+ ),
1494
+ ),
1495
+ ],
1496
+ const SizedBox(width: navArrowWidth),
1497
+ ],
1498
+ ),
1499
+ ],
1500
+ );
1501
+ },
1502
+ );
1503
+ }
1504
+
1505
+ /// Returns [base] shifted forward by [delta] months.
1506
+ DateTime _shiftMonth(DateTime base, int delta) {
1507
+ final int total = base.month - 1 + delta;
1508
+ final int year = base.year + (total ~/ 12);
1509
+ final int month = (total % 12) + 1;
1510
+ return DateTime(year, month);
1511
+ }
1512
+
1513
+ /// Builds the "December 2025" / "diciembre de 2025" caption for a given
1514
+ /// month, respecting the locale's month name list and month/year separator.
1515
+ String _monthYearLabel(DateTime m, KasyDatePickerLocale locale) =>
1516
+ '${locale.monthNames[m.month - 1]}${locale.monthYearSeparator}${m.year}';
1517
+
1518
+ void _toggleMode() {
1519
+ setState(() {
1520
+ _viewMode = _viewMode == _CalendarViewMode.month
1521
+ ? _CalendarViewMode.year
1522
+ : _CalendarViewMode.month;
1523
+ });
1524
+ }
1525
+
1526
+ @override
1527
+ Widget build(BuildContext context) {
1528
+ final KasyColors c = context.colors;
1529
+ final Widget content = _buildContent();
1530
+
1531
+ // Bare mode: no card decoration. The parent surface provides the background.
1532
+ if (widget.bare) return content;
1533
+
1534
+ return Material(
1535
+ color: Colors.transparent,
1536
+ child: Container(
1537
+ width: widget.calendarWidth ?? double.infinity,
1538
+ decoration: BoxDecoration(
1539
+ color: c.surface,
1540
+ // 24px radius — per Figma spec (dimensions/radius/rounded-3xl: 24).
1541
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
1542
+ boxShadow: _kCalendarShadows,
1543
+ ),
1544
+ child: Padding(
1545
+ // Figma: no top padding — nav row provides its own internal spacing.
1546
+ padding: const EdgeInsets.only(
1547
+ left: KasySpacing.md,
1548
+ right: KasySpacing.md,
1549
+ bottom: KasySpacing.md,
1550
+ ),
1551
+ child: content,
1552
+ ),
1553
+ ),
1554
+ );
1555
+ }
1556
+ }
1557
+
1558
+ // ─────────────────────────────────────────────────────────────────────────────
1559
+ // _MonthGrid — one month worth of day cells (optionally with month/year header)
1560
+ // ─────────────────────────────────────────────────────────────────────────────
1561
+
1562
+ class _MonthGrid extends StatelessWidget {
1563
+ const _MonthGrid({
1564
+ required this.viewMonth,
1565
+ required this.range,
1566
+ required this.onDateSelected,
1567
+ required this.locale,
1568
+ required this.showHeader,
1569
+ this.minDate,
1570
+ this.maxDate,
1571
+ });
1572
+
1573
+ final DateTime viewMonth;
1574
+ final KasyDateRange? range;
1575
+ final ValueChanged<DateTime> onDateSelected;
1576
+ final KasyDatePickerLocale locale;
1577
+
1578
+ /// When true, the grid prints its own "December 2025" caption above the
1579
+ /// weekday row. Used in multi-month layouts where the global nav row only
1580
+ /// owns the prev/next arrows and each month labels itself.
1581
+ final bool showHeader;
1582
+
1583
+ final DateTime? minDate;
1584
+ final DateTime? maxDate;
1585
+
1586
+ @override
1587
+ Widget build(BuildContext context) {
1588
+ final List<_DayData> days = _buildDayGrid(
1589
+ viewMonth,
1590
+ minDate: minDate,
1591
+ maxDate: maxDate,
1592
+ weekStartDay: locale.weekStartDay,
1593
+ );
1594
+ final String monthYear =
1595
+ '${locale.monthNames[viewMonth.month - 1]}${locale.monthYearSeparator}${viewMonth.year}';
1596
+
1597
+ return Column(
1598
+ mainAxisSize: MainAxisSize.min,
1599
+ children: [
1600
+ if (showHeader)
1601
+ Padding(
1602
+ padding: const EdgeInsets.only(bottom: 4),
1603
+ child: Text(
1604
+ monthYear,
1605
+ style: context.textTheme.labelLarge?.copyWith(
1606
+ color: context.colors.onSurface,
1607
+ fontWeight: FontWeight.w700,
1608
+ fontSize: 14,
1609
+ ),
1610
+ ),
1611
+ ),
1612
+ _WeekdayHeaderRow(locale: locale),
1613
+ // 8px gap between weekday header and first day row — Figma spec.
1614
+ const SizedBox(height: 8),
1615
+ for (int row = 0; row < 6; row++) ...[
1616
+ if (row > 0) const SizedBox(height: 2),
1617
+ _DayRow(
1618
+ days: days.sublist(row * 7, row * 7 + 7),
1619
+ range: range,
1620
+ onDateSelected: onDateSelected,
1621
+ ),
1622
+ ],
1623
+ ],
1624
+ );
1625
+ }
1626
+ }
1627
+
1628
+ // ─────────────────────────────────────────────────────────────────────────────
1629
+ // _CalendarNavRow — Month/year header with prev/next navigation
1630
+ // ─────────────────────────────────────────────────────────────────────────────
1631
+
1632
+ class _CalendarNavRow extends StatelessWidget {
1633
+ const _CalendarNavRow({
1634
+ required this.viewMonth,
1635
+ required this.viewMode,
1636
+ required this.onToggleMode,
1637
+ required this.onPrevious,
1638
+ required this.onNext,
1639
+ required this.locale,
1640
+ this.style = KasyDatePickerNavStyle.multi,
1641
+ });
1642
+
1643
+ final DateTime viewMonth;
1644
+ final _CalendarViewMode viewMode;
1645
+ final VoidCallback onToggleMode;
1646
+
1647
+ /// Null in year mode — arrows render disabled and the user picks the year
1648
+ /// by scrolling the year list.
1649
+ final VoidCallback? onPrevious;
1650
+ final VoidCallback? onNext;
1651
+ final KasyDatePickerLocale locale;
1652
+ final KasyDatePickerNavStyle style;
1653
+
1654
+ @override
1655
+ Widget build(BuildContext context) {
1656
+ final KasyColors c = context.colors;
1657
+ final String monthYear =
1658
+ '${locale.monthNames[viewMonth.month - 1]}${locale.monthYearSeparator}${viewMonth.year}';
1659
+
1660
+ // Month/year text + chevron used to toggle the year picker — shared by
1661
+ // both nav styles, only its position in the row changes.
1662
+ final Widget monthLabel = GestureDetector(
1663
+ onTap: onToggleMode,
1664
+ behavior: HitTestBehavior.opaque,
1665
+ child: Row(
1666
+ mainAxisSize: MainAxisSize.min,
1667
+ children: [
1668
+ Text(
1669
+ monthYear,
1670
+ style: context.textTheme.labelLarge?.copyWith(
1671
+ color: c.onSurface,
1672
+ fontWeight: FontWeight.w700,
1673
+ fontSize: 14,
1674
+ ),
1675
+ ),
1676
+ const SizedBox(width: 4),
1677
+ Icon(
1678
+ viewMode == _CalendarViewMode.month
1679
+ ? KasyIcons.chevronRight
1680
+ : KasyIcons.chevronDown,
1681
+ size: 18,
1682
+ weight: 700,
1683
+ color: c.primary,
1684
+ ),
1685
+ ],
1686
+ ),
1687
+ );
1688
+
1689
+ // Multi style: ‹ December 2025 › (arrows on the edges, label centered)
1690
+ if (style == KasyDatePickerNavStyle.multi) {
1691
+ return SizedBox(
1692
+ height: 56,
1693
+ child: Row(
1694
+ children: [
1695
+ _NavArrowButton(
1696
+ icon: KasyIcons.arrowBackIos,
1697
+ onTap: onPrevious,
1698
+ ),
1699
+ Expanded(child: Center(child: monthLabel)),
1700
+ _NavArrowButton(
1701
+ icon: KasyIcons.arrowForwardIos,
1702
+ onTap: onNext,
1703
+ ),
1704
+ ],
1705
+ ),
1706
+ );
1707
+ }
1708
+
1709
+ // Single style: December 2025 … ‹ › (label left, arrows clustered right)
1710
+ return SizedBox(
1711
+ height: 56,
1712
+ child: Row(
1713
+ children: [
1714
+ Expanded(child: monthLabel),
1715
+ _NavArrowButton(
1716
+ icon: KasyIcons.arrowBackIos,
1717
+ onTap: onPrevious,
1718
+ ),
1719
+ const SizedBox(width: 12),
1720
+ _NavArrowButton(
1721
+ icon: KasyIcons.arrowForwardIos,
1722
+ onTap: onNext,
1723
+ ),
1724
+ ],
1725
+ ),
1726
+ );
1727
+ }
1728
+ }
1729
+
1730
+ // ─────────────────────────────────────────────────────────────────────────────
1731
+ // _NavArrowButton — 28×28 rounded navigation arrow
1732
+ // ─────────────────────────────────────────────────────────────────────────────
1733
+
1734
+ class _NavArrowButton extends StatelessWidget {
1735
+ const _NavArrowButton({required this.icon, required this.onTap});
1736
+
1737
+ final IconData icon;
1738
+
1739
+ /// When null the button is rendered disabled: muted icon color, no hover
1740
+ /// affordance, no tap action. Used in the year picker mode where year
1741
+ /// selection happens via the scrollable list, not the arrows.
1742
+ final VoidCallback? onTap;
1743
+
1744
+ @override
1745
+ Widget build(BuildContext context) {
1746
+ final KasyColors c = context.colors;
1747
+ final bool disabled = onTap == null;
1748
+
1749
+ final Widget body = SizedBox(
1750
+ // Button size: 28×28 — per Figma spec (20px icon + 4px padding each side).
1751
+ width: 28,
1752
+ height: 28,
1753
+ child: Center(
1754
+ child: Icon(
1755
+ icon,
1756
+ size: 20,
1757
+ weight: 700,
1758
+ color: disabled ? c.muted.withValues(alpha: 0.45) : c.primary,
1759
+ ),
1760
+ ),
1761
+ );
1762
+
1763
+ if (disabled) return body;
1764
+
1765
+ // 4px padding → 6px radius — per Figma (rounded-md: 6).
1766
+ return KasyHover(
1767
+ onTap: onTap!,
1768
+ borderRadius: BorderRadius.circular(6),
1769
+ child: body,
1770
+ );
1771
+ }
1772
+ }
1773
+
1774
+ // ─────────────────────────────────────────────────────────────────────────────
1775
+ // _YearPickerContent — scrollable 3-column year list (1900–2099)
1776
+ // ─────────────────────────────────────────────────────────────────────────────
1777
+ //
1778
+ // Sized to match the month grid's intrinsic height (weekday header 36 + 8 gap
1779
+ // + 6 day rows × 36 + 5 inter-row gaps × 2 = 270) so the panel height stays
1780
+ // constant when toggling between month and year modes.
1781
+
1782
+ class _YearPickerContent extends StatefulWidget {
1783
+ const _YearPickerContent({
1784
+ required this.currentYear,
1785
+ required this.onYearSelected,
1786
+ });
1787
+
1788
+ /// The year shown in the nav row (widget.viewMonth.year).
1789
+ final int currentYear;
1790
+
1791
+ /// Called when the user taps a year cell.
1792
+ final ValueChanged<int> onYearSelected;
1793
+
1794
+ @override
1795
+ State<_YearPickerContent> createState() => _YearPickerContentState();
1796
+ }
1797
+
1798
+ class _YearPickerContentState extends State<_YearPickerContent> {
1799
+ static const int _kFirstYear = 1900;
1800
+ static const int _kLastYear = 2099;
1801
+ static const int _kCols = 3;
1802
+ static const double _kRowHeight = 36;
1803
+ static const double _kViewportHeight = 270;
1804
+
1805
+ late final ScrollController _scrollController;
1806
+
1807
+ @override
1808
+ void initState() {
1809
+ super.initState();
1810
+ // Center the current year's row on first paint.
1811
+ final int index = widget.currentYear - _kFirstYear;
1812
+ final int row = index ~/ _kCols;
1813
+ final double targetOffset =
1814
+ (row * _kRowHeight) - (_kViewportHeight - _kRowHeight) / 2;
1815
+ final int totalRows =
1816
+ ((_kLastYear - _kFirstYear + 1) / _kCols).ceil();
1817
+ final double maxOffset =
1818
+ (totalRows * _kRowHeight) - _kViewportHeight;
1819
+ final double clampedOffset = targetOffset.clamp(0.0, maxOffset);
1820
+ _scrollController = ScrollController(initialScrollOffset: clampedOffset);
1821
+ }
1822
+
1823
+ @override
1824
+ void dispose() {
1825
+ _scrollController.dispose();
1826
+ super.dispose();
1827
+ }
1828
+
1829
+ @override
1830
+ Widget build(BuildContext context) {
1831
+ final KasyColors c = context.colors;
1832
+ const int totalYears = _kLastYear - _kFirstYear + 1;
1833
+ final int rowCount = (totalYears / _kCols).ceil();
1834
+
1835
+ return Container(
1836
+ height: _kViewportHeight,
1837
+ decoration: BoxDecoration(
1838
+ borderRadius: BorderRadius.circular(KasyRadius.md),
1839
+ border: Border.all(
1840
+ color: c.outline.withValues(alpha: 0.35),
1841
+ ),
1842
+ ),
1843
+ clipBehavior: Clip.antiAlias,
1844
+ child: ListView.builder(
1845
+ controller: _scrollController,
1846
+ padding: const EdgeInsets.symmetric(vertical: 8),
1847
+ itemCount: rowCount,
1848
+ itemExtent: _kRowHeight,
1849
+ itemBuilder: (context, row) {
1850
+ return Row(
1851
+ children: List.generate(_kCols, (col) {
1852
+ final int index = row * _kCols + col;
1853
+ if (index >= totalYears) {
1854
+ return const Expanded(child: SizedBox.shrink());
1855
+ }
1856
+ final int year = _kFirstYear + index;
1857
+ final bool isCurrent = year == widget.currentYear;
1858
+
1859
+ final BorderRadius yearRadius =
1860
+ BorderRadius.circular(KasyRadius.xl);
1861
+
1862
+ return Expanded(
1863
+ child: Padding(
1864
+ padding: const EdgeInsets.symmetric(
1865
+ horizontal: 4,
1866
+ vertical: 2,
1867
+ ),
1868
+ child: KasyHover(
1869
+ onTap: () => widget.onYearSelected(year),
1870
+ borderRadius: yearRadius,
1871
+ child: DecoratedBox(
1872
+ decoration: BoxDecoration(
1873
+ color: isCurrent ? c.primary : Colors.transparent,
1874
+ borderRadius: yearRadius,
1875
+ // Same subtle lift as the selected day cell so the
1876
+ // current-year pill reads as elevated.
1877
+ boxShadow:
1878
+ isCurrent ? [KasyShadows.component(context)] : null,
1879
+ ),
1880
+ child: Center(
1881
+ child: Text(
1882
+ year.toString(),
1883
+ style: context.textTheme.labelLarge?.copyWith(
1884
+ color: isCurrent
1885
+ ? const Color(0xFFFCFCFC)
1886
+ : c.onSurface,
1887
+ fontWeight: FontWeight.w600,
1888
+ fontSize: 14,
1889
+ ),
1890
+ ),
1891
+ ),
1892
+ ),
1893
+ ),
1894
+ ),
1895
+ );
1896
+ }),
1897
+ );
1898
+ },
1899
+ ),
1900
+ );
1901
+ }
1902
+ }
1903
+
1904
+ // ─────────────────────────────────────────────────────────────────────────────
1905
+ // _WeekdayHeaderRow — Sun Mon Tue Wed Thu Fri Sat
1906
+ // ─────────────────────────────────────────────────────────────────────────────
1907
+
1908
+ class _WeekdayHeaderRow extends StatelessWidget {
1909
+ const _WeekdayHeaderRow({required this.locale});
1910
+
1911
+ final KasyDatePickerLocale locale;
1912
+
1913
+ @override
1914
+ Widget build(BuildContext context) {
1915
+ final KasyColors c = context.colors;
1916
+
1917
+ return Row(
1918
+ children: locale.weekdayAbbreviations.map((abbr) {
1919
+ return Expanded(
1920
+ child: SizedBox(
1921
+ height: 36,
1922
+ child: Center(
1923
+ child: Text(
1924
+ abbr,
1925
+ style: context.textTheme.labelMedium?.copyWith(
1926
+ color: c.muted,
1927
+ fontSize: 14,
1928
+ fontWeight: FontWeight.w600,
1929
+ height: 1.43,
1930
+ ),
1931
+ ),
1932
+ ),
1933
+ ),
1934
+ );
1935
+ }).toList(),
1936
+ );
1937
+ }
1938
+ }
1939
+
1940
+ // ─────────────────────────────────────────────────────────────────────────────
1941
+ // _DayRow — one week row (7 day cells)
1942
+ // ─────────────────────────────────────────────────────────────────────────────
1943
+
1944
+ class _DayRow extends StatelessWidget {
1945
+ const _DayRow({
1946
+ required this.days,
1947
+ required this.range,
1948
+ required this.onDateSelected,
1949
+ });
1950
+
1951
+ final List<_DayData> days;
1952
+ final KasyDateRange? range;
1953
+ final ValueChanged<DateTime> onDateSelected;
1954
+
1955
+ @override
1956
+ Widget build(BuildContext context) {
1957
+ return Row(
1958
+ children: days.map((day) {
1959
+ final _RangePosition position = _positionFor(day.date, range);
1960
+ return Expanded(
1961
+ child: _DayCell(
1962
+ data: day,
1963
+ position: position,
1964
+ onTap: day.isDisabled ? null : () => onDateSelected(day.date),
1965
+ ),
1966
+ );
1967
+ }).toList(),
1968
+ );
1969
+ }
1970
+ }
1971
+
1972
+ // ─────────────────────────────────────────────────────────────────────────────
1973
+ // _DayCell — individual calendar day
1974
+ // ─────────────────────────────────────────────────────────────────────────────
1975
+
1976
+ class _DayCell extends StatelessWidget {
1977
+ const _DayCell({
1978
+ required this.data,
1979
+ required this.position,
1980
+ required this.onTap,
1981
+ });
1982
+
1983
+ final _DayData data;
1984
+
1985
+ /// Where this day sits relative to the current selection. In single-date
1986
+ /// mode the picker wraps the [value] in a range with `start == end`, so
1987
+ /// the same component handles both modes.
1988
+ final _RangePosition position;
1989
+ final VoidCallback? onTap;
1990
+
1991
+ bool get _isEndpoint =>
1992
+ position == _RangePosition.start ||
1993
+ position == _RangePosition.end ||
1994
+ position == _RangePosition.single;
1995
+
1996
+ bool get _isInRange => position != _RangePosition.none;
1997
+
1998
+ @override
1999
+ Widget build(BuildContext context) {
2000
+ final KasyColors c = context.colors;
2001
+ final BorderRadius cellRadius = BorderRadius.circular(KasyRadius.xl);
2002
+
2003
+ // ── Text color ─────────────────────────────────────────────────────────
2004
+ final Color textColor;
2005
+ if (_isEndpoint) {
2006
+ textColor = const Color(0xFFFCFCFC); // on primary
2007
+ } else if (position == _RangePosition.middle) {
2008
+ textColor = c.onSurface; // dark over soft tint
2009
+ } else if (data.isToday) {
2010
+ textColor = c.primary;
2011
+ } else if (!data.isCurrentMonth || data.isDisabled) {
2012
+ textColor = c.muted;
2013
+ } else {
2014
+ textColor = c.onSurface;
2015
+ }
2016
+
2017
+ // ── Today dot ──────────────────────────────────────────────────────────
2018
+ final bool showDot = data.isToday;
2019
+ final Color dotColor = _isEndpoint ? const Color(0xFFFCFCFC) : c.muted;
2020
+
2021
+ // ── Out-of-month / disabled fade ───────────────────────────────────────
2022
+ final double opacity =
2023
+ (!data.isCurrentMonth || data.isDisabled) ? 0.5 : 1.0;
2024
+
2025
+ // ── Range bar (only for start/middle/end — single is just the circle) ─
2026
+ // The bar is the soft primary tint that connects start ↔ middle ↔ end.
2027
+ //
2028
+ // Geometry trick: instead of building the bar from a half-column
2029
+ // rectangle PLUS a pill behind the endpoint (which leaves visible square
2030
+ // corners above/below the ball), we use a single padded ColoredBox per
2031
+ // cell whose LEFT/RIGHT inset is exactly the column's "side gutter"
2032
+ // (column width minus 36, divided by two). The bar then starts/ends at
2033
+ // the ball's vertical tangent and carries a radius of 18 (= ball radius)
2034
+ // on the side touching the endpoint — that radius matches the ball's
2035
+ // curvature exactly, so the bar visually "hugs" the primary circle with
2036
+ // no stray corners on top or bottom.
2037
+ Widget? rangeBar;
2038
+ if (_isInRange && position != _RangePosition.single) {
2039
+ final Color barColor = c.primary.withValues(alpha: 0.10);
2040
+ const double ballSize = 36;
2041
+ const Radius ballR = Radius.circular(ballSize / 2);
2042
+
2043
+ rangeBar = LayoutBuilder(
2044
+ builder: (context, constraints) {
2045
+ // Side gutter = the empty space between the column's edge and the
2046
+ // endpoint circle. Clamped to 0 in case the cell is smaller than
2047
+ // the ball (defensive — shouldn't happen in practice).
2048
+ final double sideGutter =
2049
+ ((constraints.maxWidth - ballSize) / 2).clamp(0.0, double.infinity);
2050
+
2051
+ switch (position) {
2052
+ case _RangePosition.start:
2053
+ return Padding(
2054
+ padding: EdgeInsets.only(left: sideGutter),
2055
+ child: DecoratedBox(
2056
+ decoration: BoxDecoration(
2057
+ color: barColor,
2058
+ borderRadius: const BorderRadius.only(
2059
+ topLeft: ballR,
2060
+ bottomLeft: ballR,
2061
+ ),
2062
+ ),
2063
+ ),
2064
+ );
2065
+ case _RangePosition.end:
2066
+ return Padding(
2067
+ padding: EdgeInsets.only(right: sideGutter),
2068
+ child: DecoratedBox(
2069
+ decoration: BoxDecoration(
2070
+ color: barColor,
2071
+ borderRadius: const BorderRadius.only(
2072
+ topRight: ballR,
2073
+ bottomRight: ballR,
2074
+ ),
2075
+ ),
2076
+ ),
2077
+ );
2078
+ case _RangePosition.middle:
2079
+ return ColoredBox(color: barColor);
2080
+ case _RangePosition.single:
2081
+ case _RangePosition.none:
2082
+ return const SizedBox.shrink();
2083
+ }
2084
+ },
2085
+ );
2086
+ }
2087
+
2088
+ // ── Endpoint circle (start / end / single) ─────────────────────────────
2089
+ Widget? endpoint;
2090
+ if (_isEndpoint) {
2091
+ endpoint = Container(
2092
+ width: 36,
2093
+ height: 36,
2094
+ decoration: BoxDecoration(
2095
+ color: c.primary,
2096
+ borderRadius: cellRadius,
2097
+ boxShadow: [KasyShadows.component(context)],
2098
+ ),
2099
+ );
2100
+ }
2101
+
2102
+ final Widget textAndDot = Stack(
2103
+ alignment: Alignment.center,
2104
+ children: [
2105
+ Text(
2106
+ data.date.day.toString(),
2107
+ style: context.textTheme.labelLarge?.copyWith(
2108
+ color: textColor,
2109
+ fontWeight: FontWeight.w600,
2110
+ fontSize: 14,
2111
+ height: 1.43,
2112
+ ),
2113
+ ),
2114
+ if (showDot)
2115
+ Positioned(
2116
+ bottom: 4,
2117
+ child: Container(
2118
+ width: 3,
2119
+ height: 3,
2120
+ decoration: BoxDecoration(
2121
+ color: dotColor,
2122
+ borderRadius: BorderRadius.circular(12),
2123
+ ),
2124
+ ),
2125
+ ),
2126
+ ],
2127
+ );
2128
+
2129
+ // Inner cell content: endpoint circle (if any) + text. Stays 36×36 so it
2130
+ // can be wrapped by a circular hover overlay.
2131
+ final Widget cellInner = Stack(
2132
+ alignment: Alignment.center,
2133
+ children: [
2134
+ if (endpoint != null) endpoint,
2135
+ textAndDot,
2136
+ ],
2137
+ );
2138
+
2139
+ // The interactive bit: always a 36×36 centered circle. Hover/press
2140
+ // feedback from [KasyHover] therefore always renders as a perfect bola,
2141
+ // never an oval or square — even on range cells where the background
2142
+ // bar extends past the circle.
2143
+ final Widget interactive = Center(
2144
+ child: SizedBox(
2145
+ width: 36,
2146
+ height: 36,
2147
+ child: onTap == null
2148
+ ? cellInner
2149
+ : KasyHover(
2150
+ onTap: onTap!,
2151
+ borderRadius: cellRadius,
2152
+ child: cellInner,
2153
+ ),
2154
+ ),
2155
+ );
2156
+
2157
+ final Widget composed = SizedBox(
2158
+ height: 36,
2159
+ child: Stack(
2160
+ alignment: Alignment.center,
2161
+ children: [
2162
+ // Range bar sits behind everything else and is not interactive on
2163
+ // its own (the 36×36 [interactive] above owns the gesture region).
2164
+ if (rangeBar != null) Positioned.fill(child: rangeBar),
2165
+ interactive,
2166
+ ],
2167
+ ),
2168
+ );
2169
+
2170
+ return Opacity(opacity: opacity, child: composed);
2171
+ }
2172
+ }
2173
+