kasy-cli 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/kasy.js +3 -1
  2. package/lib/commands/new.js +99 -105
  3. package/lib/commands/run.js +34 -6
  4. package/lib/scaffold/backends/firebase/setup-from-scratch.js +79 -0
  5. package/lib/utils/brand.js +1 -1
  6. package/lib/utils/i18n/messages-en.js +6 -0
  7. package/lib/utils/i18n/messages-es.js +6 -0
  8. package/lib/utils/i18n/messages-pt.js +6 -0
  9. package/package.json +1 -2
  10. package/templates/firebase/lib/components/kasy_date_picker.dart +1670 -331
  11. package/templates/firebase/lib/components/kasy_tabs.dart +111 -72
  12. package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
  13. package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
  14. package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
  15. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
  16. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
  17. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
  18. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
  19. package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
  20. package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
  21. package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
  22. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
  23. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
  24. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
  25. package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
  26. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +457 -73
  27. package/templates/firebase/lib/features/home/home_page.dart +17 -40
  28. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
  29. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
  30. package/templates/firebase/lib/main.dart +34 -34
  31. package/templates/firebase/pubspec.yaml +1 -0
  32. package/templates/firebase/storage.cors.json +8 -0
  33. package/templates/firebase/web/index.html +15 -2
  34. package/templates/firebase/lib/core/bottom_menu/kasy_bart_navigation.dart +0 -22
@@ -1,5 +1,9 @@
1
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';
2
5
  import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
3
7
 
4
8
  // ─────────────────────────────────────────────────────────────────────────────
5
9
  // Data helpers
@@ -17,11 +21,63 @@ const List<String> _kWeekdayAbbreviations = [
17
21
  /// Returns the number of days in [month] of [year].
18
22
  int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
19
23
 
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}';
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
+ }
25
81
 
26
82
  // ─────────────────────────────────────────────────────────────────────────────
27
83
  // _DayData — internal model for a single calendar cell
@@ -49,14 +105,16 @@ List<_DayData> _buildDayGrid(
49
105
  DateTime viewMonth, {
50
106
  DateTime? minDate,
51
107
  DateTime? maxDate,
108
+ int weekStartDay = 0,
52
109
  }) {
53
110
  final DateTime today = DateTime.now();
54
111
  final int daysInCurrent = _daysInMonth(viewMonth.year, viewMonth.month);
55
112
 
56
- // Grid starts on Sunday (index 0).
57
- // Dart weekday: 1=Mon 7=Sun sunday offset = weekday % 7.
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.
58
116
  final DateTime firstOfMonth = DateTime(viewMonth.year, viewMonth.month);
59
- final int offset = firstOfMonth.weekday % 7;
117
+ final int offset = (firstOfMonth.weekday % 7 - weekStartDay + 7) % 7;
60
118
 
61
119
  // Previous-month fill.
62
120
  final int prevYear =
@@ -128,25 +186,7 @@ bool _isSameDay(DateTime a, DateTime b) =>
128
186
  // Design constants (from Figma variable defs)
129
187
  // ─────────────────────────────────────────────────────────────────────────────
130
188
 
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.
189
+ // Calendar overlay shadow — from Figma variable defs (3 layers).
150
190
  const List<BoxShadow> _kCalendarShadows = [
151
191
  BoxShadow(
152
192
  color: Color(0x14000000), // rgba(0,0,0,0.08)
@@ -154,7 +194,7 @@ const List<BoxShadow> _kCalendarShadows = [
154
194
  offset: Offset(0, 14),
155
195
  ),
156
196
  BoxShadow(
157
- color: Color(0x08000000), // rgba(0,0,0,0.03)
197
+ color: Color(0x07000000), // rgba(0,0,0,0.027)
158
198
  blurRadius: 12,
159
199
  offset: Offset(0, -6),
160
200
  ),
@@ -165,14 +205,200 @@ const List<BoxShadow> _kCalendarShadows = [
165
205
  ),
166
206
  ];
167
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
+
168
389
  // ─────────────────────────────────────────────────────────────────────────────
169
390
  // KasyDatePicker
170
391
  // ─────────────────────────────────────────────────────────────────────────────
171
392
 
172
393
  /// Date picker combining a text-field trigger with a floating calendar overlay.
173
394
  ///
174
- /// Tapping the field opens a calendar below the trigger. Selecting a day
175
- /// calls [onChanged] and closes the calendar.
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.
176
402
  ///
177
403
  /// Usage:
178
404
  /// ```dart
@@ -188,21 +414,53 @@ class KasyDatePicker extends StatefulWidget {
188
414
  this.label,
189
415
  this.value,
190
416
  this.onChanged,
417
+ this.range,
418
+ this.onRangeChanged,
419
+ this.selectionMode = KasyDateSelectionMode.single,
191
420
  this.minDate,
192
421
  this.maxDate,
193
422
  this.enabled = true,
194
- this.placeholder = 'mm / dd / yyyy',
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,
195
437
  });
196
438
 
197
439
  /// Label displayed above the field. Omit to hide.
198
440
  final String? label;
199
441
 
200
- /// Currently selected date. Pass null for no selection.
442
+ /// Currently selected date only used in [KasyDateSelectionMode.single].
443
+ /// Pass null for no selection.
201
444
  final DateTime? value;
202
445
 
203
- /// Called when the user picks a date.
446
+ /// Called when the user picks a date in [KasyDateSelectionMode.single].
204
447
  final ValueChanged<DateTime>? onChanged;
205
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
+
206
464
  /// Earliest selectable date (inclusive).
207
465
  final DateTime? minDate;
208
466
 
@@ -211,8 +469,72 @@ class KasyDatePicker extends StatefulWidget {
211
469
 
212
470
  final bool enabled;
213
471
 
214
- /// Placeholder text shown when no date is selected.
215
- final String placeholder;
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;
216
538
 
217
539
  @override
218
540
  State<KasyDatePicker> createState() => _KasyDatePickerState();
@@ -226,11 +548,50 @@ class _KasyDatePickerState extends State<KasyDatePicker>
226
548
  // Unique ID so TapRegion knows "field + calendar" form one group.
227
549
  final Object _tapGroupId = Object();
228
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
+
229
564
  // Month currently displayed in the calendar (always first of the month).
230
565
  late DateTime _viewMonth;
231
566
 
232
567
  bool _isOpen = false;
233
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
+
234
595
  late final AnimationController _animCtrl;
235
596
  late final Animation<double> _fadeAnim;
236
597
  late final Animation<double> _scaleAnim;
@@ -238,7 +599,8 @@ class _KasyDatePickerState extends State<KasyDatePicker>
238
599
  @override
239
600
  void initState() {
240
601
  super.initState();
241
- _viewMonth = _monthOf(widget.value ?? DateTime.now());
602
+ _viewMonth = _monthOf(_initialAnchorDate() ?? DateTime.now());
603
+ _displayController = TextEditingController(text: _displayText());
242
604
  _animCtrl = AnimationController(
243
605
  vsync: this,
244
606
  duration: const Duration(milliseconds: 180),
@@ -256,46 +618,314 @@ class _KasyDatePickerState extends State<KasyDatePicker>
256
618
  @override
257
619
  void didUpdateWidget(KasyDatePicker old) {
258
620
  super.didUpdateWidget(old);
259
- if (old.value != widget.value && widget.value != null) {
260
- _viewMonth = _monthOf(widget.value!);
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);
261
634
  }
635
+ _displayController.text = _displayText();
262
636
  }
263
637
 
264
638
  @override
265
639
  void dispose() {
640
+ _displayController.dispose();
641
+ _fieldFocusNode.dispose();
266
642
  _animCtrl.dispose();
267
643
  super.dispose();
268
644
  }
269
645
 
270
646
  DateTime _monthOf(DateTime d) => DateTime(d.year, d.month);
271
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
+
272
698
  // ── Calendar toggle ────────────────────────────────────────────────────────
273
699
 
274
700
  void _toggleCalendar() {
275
701
  if (!widget.enabled) return;
276
- if (_isOpen) {
277
- _close();
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();
278
710
  } else {
279
- _open();
711
+ _openBottomSheet();
280
712
  }
281
713
  }
282
714
 
283
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
+
284
741
  _portalController.show();
285
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();
286
747
  setState(() => _isOpen = true);
287
748
  }
288
749
 
289
750
  void _close() {
290
751
  _animCtrl.reverse().then((_) {
291
- if (mounted) _portalController.hide();
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();
292
768
  });
293
769
  setState(() => _isOpen = false);
294
770
  }
295
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.
296
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
+ }
297
790
  widget.onChanged?.call(date);
298
- _close();
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
+ });
299
929
  }
300
930
 
301
931
  void _previousMonth() {
@@ -320,177 +950,591 @@ class _KasyDatePickerState extends State<KasyDatePicker>
320
950
 
321
951
  @override
322
952
  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
- ),
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),
334
962
  ),
335
- ),
336
- );
963
+ );
964
+ }
965
+
966
+ return _buildField(context);
337
967
  }
338
968
 
339
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.
340
979
 
341
980
  Widget _buildField(BuildContext context) {
342
981
  final KasyColors c = context.colors;
343
- final DateTime? date = widget.value;
344
- final bool hasDate = date != null;
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;
345
989
 
346
990
  return Column(
347
991
  crossAxisAlignment: CrossAxisAlignment.start,
348
992
  mainAxisSize: MainAxisSize.min,
349
993
  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,
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,
397
1007
  ),
398
1008
  ),
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,
1009
+ if (widget.showRequiredIndicator)
1010
+ Text(
1011
+ ' *',
1012
+ style: context.textTheme.bodyMedium?.copyWith(
1013
+ color: c.error,
1014
+ fontWeight: FontWeight.w500,
411
1015
  ),
412
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,
413
1055
  ),
414
1056
  ),
415
- ],
1057
+ ),
416
1058
  ),
417
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
+ ],
418
1078
  ],
419
1079
  );
420
1080
  }
421
1081
 
422
- // ── Calendar overlay ───────────────────────────────────────────────────────
1082
+ // ── Calendar overlay (popover only) ───────────────────────────────────────
423
1083
 
424
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
+
425
1092
  return TapRegion(
426
1093
  groupId: _tapGroupId,
427
1094
  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,
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
+ ),
448
1144
  ),
449
1145
  ),
450
1146
  ),
451
- ),
1147
+ ],
452
1148
  ),
453
1149
  );
454
1150
  }
455
1151
  }
456
1152
 
457
1153
  // ─────────────────────────────────────────────────────────────────────────────
458
- // _CalendarPanel
1154
+ // _BottomSheetContent — KasyBottomSheet-style wrapper for the calendar
459
1155
  // ─────────────────────────────────────────────────────────────────────────────
460
1156
 
461
- class _CalendarPanel extends StatelessWidget {
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 {
462
1308
  const _CalendarPanel({
463
1309
  required this.viewMonth,
464
- required this.selectedDate,
1310
+ required this.range,
465
1311
  required this.onDateSelected,
466
1312
  required this.onPreviousMonth,
467
1313
  required this.onNextMonth,
1314
+ required this.onYearSelected,
1315
+ this.calendarWidth,
468
1316
  this.minDate,
469
1317
  this.maxDate,
1318
+ this.bare = false,
1319
+ this.locale = KasyDatePickerLocale.en,
1320
+ this.monthsToShow,
470
1321
  });
471
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
+
472
1327
  final DateTime viewMonth;
473
- final DateTime? selectedDate;
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;
474
1333
  final ValueChanged<DateTime> onDateSelected;
475
1334
  final VoidCallback onPreviousMonth;
476
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;
477
1342
  final DateTime? minDate;
478
1343
  final DateTime? maxDate;
479
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
+
480
1526
  @override
481
1527
  Widget build(BuildContext context) {
482
1528
  final KasyColors c = context.colors;
483
- final List<_DayData> days = _buildDayGrid(
484
- viewMonth,
485
- minDate: minDate,
486
- maxDate: maxDate,
487
- );
1529
+ final Widget content = _buildContent();
1530
+
1531
+ // Bare mode: no card decoration. The parent surface provides the background.
1532
+ if (widget.bare) return content;
488
1533
 
489
1534
  return Material(
490
1535
  color: Colors.transparent,
491
1536
  child: Container(
492
- // Width matches the DateField — 288px per Figma spec.
493
- width: 288,
1537
+ width: widget.calendarWidth ?? double.infinity,
494
1538
  decoration: BoxDecoration(
495
1539
  color: c.surface,
496
1540
  // 24px radius — per Figma spec (dimensions/radius/rounded-3xl: 24).
@@ -498,36 +1542,89 @@ class _CalendarPanel extends StatelessWidget {
498
1542
  boxShadow: _kCalendarShadows,
499
1543
  ),
500
1544
  child: Padding(
501
- // 16px all sidesper 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
- ],
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,
524
1550
  ),
1551
+ child: content,
525
1552
  ),
526
1553
  ),
527
1554
  );
528
1555
  }
529
1556
  }
530
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
+
531
1628
  // ─────────────────────────────────────────────────────────────────────────────
532
1629
  // _CalendarNavRow — Month/year header with prev/next navigation
533
1630
  // ─────────────────────────────────────────────────────────────────────────────
@@ -535,61 +1632,91 @@ class _CalendarPanel extends StatelessWidget {
535
1632
  class _CalendarNavRow extends StatelessWidget {
536
1633
  const _CalendarNavRow({
537
1634
  required this.viewMonth,
1635
+ required this.viewMode,
1636
+ required this.onToggleMode,
538
1637
  required this.onPrevious,
539
1638
  required this.onNext,
1639
+ required this.locale,
1640
+ this.style = KasyDatePickerNavStyle.multi,
540
1641
  });
541
1642
 
542
1643
  final DateTime viewMonth;
543
- final VoidCallback onPrevious;
544
- final VoidCallback onNext;
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;
545
1653
 
546
1654
  @override
547
1655
  Widget build(BuildContext context) {
548
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
+ );
549
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)
550
1710
  return SizedBox(
551
- // Navigation row height: 56px (16px padding top+bottom) — Figma spec.
552
1711
  height: 56,
553
1712
  child: Row(
554
1713
  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.
1714
+ Expanded(child: monthLabel),
587
1715
  _NavArrowButton(
588
1716
  icon: KasyIcons.arrowBackIos,
589
1717
  onTap: onPrevious,
590
1718
  ),
591
- const SizedBox(width: 4),
592
- // Next-month arrow button.
1719
+ const SizedBox(width: 12),
593
1720
  _NavArrowButton(
594
1721
  icon: KasyIcons.arrowForwardIos,
595
1722
  onTap: onNext,
@@ -601,49 +1728,175 @@ class _CalendarNavRow extends StatelessWidget {
601
1728
  }
602
1729
 
603
1730
  // ─────────────────────────────────────────────────────────────────────────────
604
- // _NavArrowButton — 24×24 rounded navigation arrow
1731
+ // _NavArrowButton — 28×28 rounded navigation arrow
605
1732
  // ─────────────────────────────────────────────────────────────────────────────
606
1733
 
607
- class _NavArrowButton extends StatefulWidget {
1734
+ class _NavArrowButton extends StatelessWidget {
608
1735
  const _NavArrowButton({required this.icon, required this.onTap});
609
1736
 
610
1737
  final IconData icon;
611
- final VoidCallback onTap;
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;
612
1743
 
613
1744
  @override
614
- State<_NavArrowButton> createState() => _NavArrowButtonState();
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();
615
1796
  }
616
1797
 
617
- class _NavArrowButtonState extends State<_NavArrowButton> {
618
- bool _hovered = false;
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
+ }
619
1828
 
620
1829
  @override
621
1830
  Widget build(BuildContext context) {
622
1831
  final KasyColors c = context.colors;
1832
+ const int totalYears = _kLastYear - _kFirstYear + 1;
1833
+ final int rowCount = (totalYears / _kCols).ceil();
623
1834
 
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
- ),
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),
645
1841
  ),
646
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
+ ),
647
1900
  );
648
1901
  }
649
1902
  }
@@ -653,14 +1906,16 @@ class _NavArrowButtonState extends State<_NavArrowButton> {
653
1906
  // ─────────────────────────────────────────────────────────────────────────────
654
1907
 
655
1908
  class _WeekdayHeaderRow extends StatelessWidget {
656
- const _WeekdayHeaderRow();
1909
+ const _WeekdayHeaderRow({required this.locale});
1910
+
1911
+ final KasyDatePickerLocale locale;
657
1912
 
658
1913
  @override
659
1914
  Widget build(BuildContext context) {
660
1915
  final KasyColors c = context.colors;
661
1916
 
662
1917
  return Row(
663
- children: _kWeekdayAbbreviations.map((abbr) {
1918
+ children: locale.weekdayAbbreviations.map((abbr) {
664
1919
  return Expanded(
665
1920
  child: SizedBox(
666
1921
  height: 36,
@@ -668,10 +1923,10 @@ class _WeekdayHeaderRow extends StatelessWidget {
668
1923
  child: Text(
669
1924
  abbr,
670
1925
  style: context.textTheme.labelMedium?.copyWith(
671
- // 12px/w500 — per Figma spec (Body xs medium: text-xs).
672
1926
  color: c.muted,
673
- fontSize: 12,
674
- fontWeight: FontWeight.w500,
1927
+ fontSize: 14,
1928
+ fontWeight: FontWeight.w600,
1929
+ height: 1.43,
675
1930
  ),
676
1931
  ),
677
1932
  ),
@@ -689,27 +1944,24 @@ class _WeekdayHeaderRow extends StatelessWidget {
689
1944
  class _DayRow extends StatelessWidget {
690
1945
  const _DayRow({
691
1946
  required this.days,
692
- required this.selectedDate,
1947
+ required this.range,
693
1948
  required this.onDateSelected,
694
1949
  });
695
1950
 
696
1951
  final List<_DayData> days;
697
- final DateTime? selectedDate;
1952
+ final KasyDateRange? range;
698
1953
  final ValueChanged<DateTime> onDateSelected;
699
1954
 
700
1955
  @override
701
1956
  Widget build(BuildContext context) {
702
1957
  return Row(
703
1958
  children: days.map((day) {
704
- final bool isSelected =
705
- selectedDate != null && _isSameDay(day.date, selectedDate!);
1959
+ final _RangePosition position = _positionFor(day.date, range);
706
1960
  return Expanded(
707
1961
  child: _DayCell(
708
1962
  data: day,
709
- isSelected: isSelected,
710
- onTap: (day.isDisabled || !day.isCurrentMonth && day.isDisabled)
711
- ? null
712
- : () => onDateSelected(day.date),
1963
+ position: position,
1964
+ onTap: day.isDisabled ? null : () => onDateSelected(day.date),
713
1965
  ),
714
1966
  );
715
1967
  }).toList(),
@@ -721,114 +1973,201 @@ class _DayRow extends StatelessWidget {
721
1973
  // _DayCell — individual calendar day
722
1974
  // ─────────────────────────────────────────────────────────────────────────────
723
1975
 
724
- class _DayCell extends StatefulWidget {
1976
+ class _DayCell extends StatelessWidget {
725
1977
  const _DayCell({
726
1978
  required this.data,
727
- required this.isSelected,
1979
+ required this.position,
728
1980
  required this.onTap,
729
1981
  });
730
1982
 
731
1983
  final _DayData data;
732
- final bool isSelected;
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;
733
1989
  final VoidCallback? onTap;
734
1990
 
735
- @override
736
- State<_DayCell> createState() => _DayCellState();
737
- }
1991
+ bool get _isEndpoint =>
1992
+ position == _RangePosition.start ||
1993
+ position == _RangePosition.end ||
1994
+ position == _RangePosition.single;
738
1995
 
739
- class _DayCellState extends State<_DayCell> {
740
- bool _pressed = false;
1996
+ bool get _isInRange => position != _RangePosition.none;
741
1997
 
742
1998
  @override
743
1999
  Widget build(BuildContext context) {
744
2000
  final KasyColors c = context.colors;
745
- final _DayData day = widget.data;
746
-
747
- // ── Color determination ────────────────────────────────────────────────
2001
+ final BorderRadius cellRadius = BorderRadius.circular(KasyRadius.xl);
748
2002
 
2003
+ // ── Text color ─────────────────────────────────────────────────────────
749
2004
  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) {
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) {
762
2012
  textColor = c.muted;
763
- bgColor = null;
764
2013
  } else {
765
- textColor = c.onSurface; // default
766
- bgColor = _pressed ? c.avatarFallbackFill : null;
2014
+ textColor = c.onSurface;
767
2015
  }
768
2016
 
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.
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 ───────────────────────────────────────
780
2022
  final double opacity =
781
- (!day.isCurrentMonth || day.isDisabled) ? 0.5 : 1.0;
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
+ }
782
2087
 
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),
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
+ ),
792
2154
  ),
2155
+ );
2156
+
2157
+ final Widget composed = SizedBox(
2158
+ height: 36,
793
2159
  child: Stack(
794
2160
  alignment: Alignment.center,
795
2161
  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
- ),
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,
818
2166
  ],
819
2167
  ),
820
2168
  );
821
2169
 
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
- );
2170
+ return Opacity(opacity: opacity, child: composed);
833
2171
  }
834
2172
  }
2173
+