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.
- package/bin/kasy.js +3 -1
- package/lib/commands/new.js +99 -105
- package/lib/commands/run.js +34 -6
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +79 -0
- package/lib/utils/brand.js +1 -1
- package/lib/utils/i18n/messages-en.js +6 -0
- package/lib/utils/i18n/messages-es.js +6 -0
- package/lib/utils/i18n/messages-pt.js +6 -0
- package/package.json +1 -2
- package/templates/firebase/lib/components/kasy_date_picker.dart +1670 -331
- package/templates/firebase/lib/components/kasy_tabs.dart +111 -72
- package/templates/firebase/lib/components/kasy_text_area.dart +9 -4
- package/templates/firebase/lib/components/kasy_text_field.dart +96 -36
- package/templates/firebase/lib/components/kasy_text_field_otp.dart +1 -2
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +88 -35
- package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +7 -43
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +118 -16
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +14 -20
- package/templates/firebase/lib/core/security/secured_storage.dart +56 -15
- package/templates/firebase/lib/core/theme/providers/theme_provider.dart +3 -0
- package/templates/firebase/lib/core/theme/web_background_sync.dart +3 -0
- package/templates/firebase/lib/core/theme/web_background_sync_web.dart +18 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -6
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +6 -0
- package/templates/firebase/lib/features/home/home_components_page.dart +3 -2
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +457 -73
- package/templates/firebase/lib/features/home/home_page.dart +17 -40
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -16
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +0 -4
- package/templates/firebase/lib/main.dart +34 -34
- package/templates/firebase/pubspec.yaml +1 -0
- package/templates/firebase/storage.cors.json +8 -0
- package/templates/firebase/web/index.html +15 -2
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
///
|
|
175
|
-
///
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
260
|
-
|
|
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 (
|
|
277
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
child:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
344
|
-
final bool
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
//
|
|
1154
|
+
// _BottomSheetContent — KasyBottomSheet-style wrapper for the calendar
|
|
459
1155
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
460
1156
|
|
|
461
|
-
class
|
|
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.
|
|
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
|
-
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
502
|
-
padding: const EdgeInsets.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
544
|
-
final VoidCallback
|
|
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
|
-
|
|
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:
|
|
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 —
|
|
1731
|
+
// _NavArrowButton — 28×28 rounded navigation arrow
|
|
605
1732
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
606
1733
|
|
|
607
|
-
class _NavArrowButton extends
|
|
1734
|
+
class _NavArrowButton extends StatelessWidget {
|
|
608
1735
|
const _NavArrowButton({required this.icon, required this.onTap});
|
|
609
1736
|
|
|
610
1737
|
final IconData icon;
|
|
611
|
-
|
|
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
|
-
|
|
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
|
|
618
|
-
|
|
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
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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:
|
|
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:
|
|
674
|
-
fontWeight: FontWeight.
|
|
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.
|
|
1947
|
+
required this.range,
|
|
693
1948
|
required this.onDateSelected,
|
|
694
1949
|
});
|
|
695
1950
|
|
|
696
1951
|
final List<_DayData> days;
|
|
697
|
-
final
|
|
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
|
|
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
|
-
|
|
710
|
-
onTap:
|
|
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
|
|
1976
|
+
class _DayCell extends StatelessWidget {
|
|
725
1977
|
const _DayCell({
|
|
726
1978
|
required this.data,
|
|
727
|
-
required this.
|
|
1979
|
+
required this.position,
|
|
728
1980
|
required this.onTap,
|
|
729
1981
|
});
|
|
730
1982
|
|
|
731
1983
|
final _DayData data;
|
|
732
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1991
|
+
bool get _isEndpoint =>
|
|
1992
|
+
position == _RangePosition.start ||
|
|
1993
|
+
position == _RangePosition.end ||
|
|
1994
|
+
position == _RangePosition.single;
|
|
738
1995
|
|
|
739
|
-
|
|
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
|
|
746
|
-
|
|
747
|
-
// ── Color determination ────────────────────────────────────────────────
|
|
2001
|
+
final BorderRadius cellRadius = BorderRadius.circular(KasyRadius.xl);
|
|
748
2002
|
|
|
2003
|
+
// ── Text color ─────────────────────────────────────────────────────────
|
|
749
2004
|
final Color textColor;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
textColor =
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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;
|
|
766
|
-
bgColor = _pressed ? c.avatarFallbackFill : null;
|
|
2014
|
+
textColor = c.onSurface;
|
|
767
2015
|
}
|
|
768
2016
|
|
|
769
|
-
// ── Today
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
(!
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
+
|