frust_material/date_picker/mod.rs
1// Ported from `material_3_expressive` v1.0.8's date-picker family (MIT, © 2026
2// Paa Developments; `tmp/material_3_expressive/lib/components/date_pickers/` —
3// `m3e_date_pickers.dart`, `m3e_calendar_date_picker.dart`,
4// `m3e_date_picker_dialog.dart`, `m3e_date_range_picker_dialog.dart`,
5// `components/{m3e_day_cell, m3e_day_picker, m3e_month_picker, m3e_year_picker,
6// m3e_date_picker_header, m3e_date_picker_actions, m3e_date_picker_mode_toggle,
7// m3e_date_picker_dialog_content, m3e_input_date_picker_form_field,
8// m3e_input_date_range_picker_form_field, m3e_calendar_date_range_picker}.dart`,
9// `enums/`, `models/`, `res/`, `styles/`, `utils/`, retrieved 2026-08-20).
10// Upstream: <https://github.com/paadevelopments/material_3_expressive>
11//
12// Porting decisions (each documented, with its reference site, in the module
13// docs below): the civil-calendar math is implemented in-crate rather than
14// taken from a date library; localization is a caller-supplied
15// `DatePickerStrings` struct with English/US defaults in place of Flutter's
16// `MaterialLocalizations`; the picker is a *controlled* component driven by one
17// `DatePickerState` value rather than the reference's `StatefulWidget` local
18// state; the landscape dialog layout and the scrolling multi-month **range**
19// dialog are v1 cuts.
20
21//! The M3 Expressive **date picker**: a calendar month grid with a year
22//! selector, a text-input mode, and the modal dialog that presents either.
23//!
24//! - [`mod@date`] — [`MaterialDate`], the whole-day value type, and its
25//! civil-calendar arithmetic; [`DateRange`], the range model.
26//! - [`mod@grid`] — the pure day/year grid geometry (leading blanks, row
27//! counts, sub-view heights).
28//! - [`mod@calendar`] — [`calendar_date_picker`], the embeddable calendar:
29//! month header with prev/next and a year-selector toggle, the 7-column day
30//! grid, the scrolling year grid, and the month-paging slide.
31//! - [`mod@input`] — [`date_input_field`], the input mode's text field with
32//! format/range validation over [`crate::text_field`].
33//! - [`mod@dialog`] — [`date_picker_dialog`]/[`show_date_picker`], the modal
34//! presentation on the shared [`crate::overlay::modal`] host.
35//!
36//! # Controlled, through one [`DatePickerState`] value
37//!
38//! The reference's `M3ECalendarDatePicker`/`M3EDatePickerDialog` are
39//! `StatefulWidget`s owning `_selectedDate`, `_displayedMonth`, `_mode` and
40//! `_entryMode` in their own `State`. This catalog's rule is the opposite —
41//! *controlled components never self-mutate* (`docs/CODE_STANDARDS.md`'s
42//! Interaction Semantics), the same stance [`crate::dialog`]'s selection dialog
43//! already takes — so all four live in one [`DatePickerState`] the app holds,
44//! and every widget here **computes and reports** the next state rather than
45//! writing its own:
46//!
47//! ```ignore
48//! calendar_date_picker(state.picker.clone(), first, last, |state: &mut App, next| {
49//! state.picker = next;
50//! })
51//! ```
52//!
53//! One prop and one callback rather than five of each: the transitions
54//! ([`DatePickerState::with_selected`], [`DatePickerState::stepped_month`],
55//! [`DatePickerState::with_year`], [`DatePickerState::toggled_entry_mode`], …)
56//! are plain, total functions on the value, so an app's `on_change` is a single
57//! assignment and the interaction rules are pinned as pure-function tests
58//! rather than inferred from a painted scene.
59//!
60//! # Localization: English defaults plus an override struct
61//!
62//! Every string and the first-day-of-week index come from
63//! [`DatePickerStrings`], which defaults to [`DatePickerStrings::ENGLISH`] —
64//! Flutter `MaterialLocalizations`' own `en_US` values, which is what the
65//! reference reads (`localizations.formatMonthYear`, `.narrowWeekdays`,
66//! `.firstDayOfWeekIndex`, `.datePickerHelpText`, `.invalidDateFormatLabel`, …)
67//! and what its own `M3ECalendarLabels` hard-codes as a fallback.
68//!
69//! **This is an adaptation, not a port of the localization itself.** This crate
70//! has no i18n seam (a design-system plugin may not depend on a sibling plugin
71//! — `docs/PLUGINS_ARCHITECTURE.md`'s Layer Dependencies — so `frust-i18n` is
72//! out of reach), and the input mode's `mm/dd/yyyy` parse/format
73//! ([`MaterialDate::format_compact`]) is likewise the `en_US` compact form
74//! rather than a locale-resolved one. An app that ships other languages
75//! substitutes its own [`DatePickerStrings`] (`..DatePickerStrings::ENGLISH`
76//! for the fields it does not translate) and reformats dates itself; a
77//! non-`mm/dd/yyyy` *input* format is not overridable in v1.
78//!
79//! # Deliberate v1 scope cuts
80//!
81//! - **No landscape dialog layout.** The reference switches
82//! `M3EDatePickerDialog` between a portrait column and a landscape row with a
83//! side header (`calendarLandscapeDialogSize`, `headerLandscapeWidth`),
84//! keyed off `MediaQuery.orientationOf`. This port ships the portrait shape
85//! only, at every window size — the same single-shape choice
86//! [`crate::dialog`] makes.
87//! - **No range *dialog*.** `M3EDateRangePickerDialog` presents a vertically
88//! scrolling list of every month between the bounds; that is a second, larger
89//! surface with its own scroll physics, which this deep in a modal's content
90//! tree has no viewport to lean on (the limit [`crate::dialog`]'s selection
91//! list records). The range **model** ([`DateRange`]) and the day cell's
92//! range visuals ([`CalendarDatePicker::range`]) do ship, so an app can drive
93//! range selection against the single-month calendar.
94//! - **No text-scale clamping.** The reference caps its own layout against
95//! `MediaQuery.textScalerOf` (`maxTextScaleFactor`, `maxHeaderTextScaleFactor`,
96//! …); the framework exposes no text-scale factor to a widget, so those
97//! constants have nothing to clamp here.
98//! - **No keyboard month paging.** `_M3EMonthPickerState._handleKey` pages on
99//! arrow-left/right while the grid holds focus; this widget claims no focus
100//! of its own (it has no editable and no focusable child), so there is no
101//! focus path for a `Key` event to reach it on.
102//!
103//! # Geometry (cited from `m3e_date_picker_constants.dart` / `m3e_date_picker_theme.dart`)
104//!
105//! [`SUB_HEADER_HEIGHT`] (52), [`WEEKDAY_ROW_HEIGHT`] (24), [`DAY_ROW_HEIGHT`]
106//! (48), [`DAY_GRID_TOP_PADDING`] (4), [`MONTH_NAV_BUTTONS_WIDTH`] (108),
107//! [`YEAR_ROW_HEIGHT`] (52), [`YEAR_ROW_SPACING`] (8), [`YEAR_GRID_PADDING`]
108//! (16), [`YEAR_COLUMN_COUNT`] (3), [`MAX_DAY_PICKER_HEIGHT`] (48 × 7),
109//! [`CALENDAR_WIDTH`] (328), [`CALENDAR_PADDING`] (12), [`DAY_SIZE`] (40),
110//! [`ARROW_ICON_SIZE`] (24), [`ARROW_PADDING`] (8), [`HEADER_PORTRAIT_HEIGHT`]
111//! (120), [`ACTIONS_MIN_HEIGHT`] (52), [`MONTH_SCROLL_DURATION`] (200ms),
112//! [`DIALOG_PORTRAIT_CALENDAR_WIDTH`] (360), [`DIALOG_PORTRAIT_INPUT_WIDTH`]
113//! (328).
114
115use std::time::Duration;
116
117use frust::authoring::TypedArgCallback;
118
119pub mod calendar;
120pub mod date;
121pub mod dialog;
122pub mod grid;
123pub mod input;
124
125pub use calendar::{CalendarDatePicker, CalendarDatePickerWidget, calendar_date_picker};
126pub use date::{DateRange, MAX_YEAR, MIN_YEAR, MaterialDate, days_in_month, is_leap_year};
127pub use dialog::{DatePickerDialog, date_picker_dialog, show_date_picker};
128pub use grid::MonthGrid;
129pub use input::{
130 DateInputError, DateInputField, DateInputFieldWidget, date_input_field, parse_bounded,
131};
132
133/// The one callback every view in this family reports through: "here is the
134/// state you should now hold". See the [module docs](self)' controlled note.
135pub type OnDatePickerChange<State> = TypedArgCallback<State, DatePickerState>;
136
137// ---- Layout constants (m3e_date_picker_constants.dart / m3e_date_picker_theme.dart) ----
138
139/// Columns in the day grid (`M3EDatePickerTheme.daysPerWeek`). A `usize`
140/// because every consumer indexes a grid with it.
141pub const DAYS_PER_WEEK: usize = 7;
142
143/// The month/year sub-header band's height, in logical px (`subHeaderHeight`).
144pub const SUB_HEADER_HEIGHT: f64 = 52.0;
145
146/// The weekday-initial header row's height, in logical px (`weekdayRowHeight`).
147pub const WEEKDAY_ROW_HEIGHT: f64 = 24.0;
148
149/// One week row's height in the day grid, in logical px (`dayPickerRowHeight`).
150pub const DAY_ROW_HEIGHT: f64 = 48.0;
151
152/// The day grid's own top inset, in logical px (`dayGridTopPadding` /
153/// `M3EDatePickerTheme.gridPadding`'s `top: 4`).
154pub const DAY_GRID_TOP_PADDING: f64 = 4.0;
155
156/// The most week rows any month occupies (`maxDayPickerRowCount`).
157pub const MAX_DAY_PICKER_ROW_COUNT: usize = 6;
158
159/// The height ceiling a scrollable sub-view is capped at, in logical px
160/// (`maxDayPickerHeight`, `dayPickerRowHeight * (maxDayPickerRowCount + 1)`).
161pub const MAX_DAY_PICKER_HEIGHT: f64 = DAY_ROW_HEIGHT * (MAX_DAY_PICKER_ROW_COUNT as f64 + 1.0);
162
163/// The width the prev/next month affordances share at the sub-header's trailing
164/// edge, in logical px (`monthNavButtonsWidth`).
165pub const MONTH_NAV_BUTTONS_WIDTH: f64 = 108.0;
166
167/// The sub-header's leading inset, in logical px — the reference's
168/// `EdgeInsetsDirectional.only(start: 16)` on both the month/year label and the
169/// year sub-view's own toggle.
170pub const SUB_HEADER_START_INSET: f64 = 16.0;
171
172/// Columns in the year grid (`yearPickerColumnCount`).
173pub const YEAR_COLUMN_COUNT: usize = 3;
174
175/// The year grid's inset on all four edges, in logical px (`yearPickerPadding`).
176pub const YEAR_GRID_PADDING: f64 = 16.0;
177
178/// One year cell's height, in logical px (`yearPickerRowHeight`).
179pub const YEAR_ROW_HEIGHT: f64 = 52.0;
180
181/// The gap between year cells, both axes, in logical px
182/// (`yearPickerRowSpacing`).
183pub const YEAR_ROW_SPACING: f64 = 8.0;
184
185/// The inline calendar's own width, in logical px (`M3EDatePickerTheme.width`)
186/// — the fallback this widget sizes to when its constraints are unbounded.
187pub const CALENDAR_WIDTH: f64 = 328.0;
188
189/// The inline calendar's padding on all four edges, in logical px
190/// (`M3EDatePickerTheme.padding`).
191pub const CALENDAR_PADDING: f64 = 12.0;
192
193/// One day cell's selected/today circle diameter, in logical px
194/// (`M3EDatePickerTheme.daySize`) — smaller than [`DAY_ROW_HEIGHT`], which is
195/// the cell's own touch-target box.
196pub const DAY_SIZE: f64 = 40.0;
197
198/// The sub-header's chevron/drop-arrow glyph size, in logical px
199/// (`M3EDatePickerTheme.arrowIconSize`).
200pub const ARROW_ICON_SIZE: f64 = 24.0;
201
202/// The padding around each sub-header chevron, in logical px
203/// (`M3EDatePickerTheme.arrowPadding`).
204pub const ARROW_PADDING: f64 = 8.0;
205
206/// The dialog header band's height, in logical px (`headerPortraitHeight`).
207pub const HEADER_PORTRAIT_HEIGHT: f64 = 120.0;
208
209/// The dialog action row's minimum height, in logical px (`actionsMinHeight`).
210pub const ACTIONS_MIN_HEIGHT: f64 = 52.0;
211
212/// The month-paging slide's duration (`monthScrollDuration`).
213pub const MONTH_SCROLL_DURATION: Duration = Duration::from_millis(200);
214
215/// The dialog's portrait width in calendar mode, in logical px
216/// (`calendarPortraitDialogSize.width`).
217pub const DIALOG_PORTRAIT_CALENDAR_WIDTH: f64 = 360.0;
218
219/// The dialog's portrait width in input mode, in logical px
220/// (`inputPortraitDialogSize.width`).
221pub const DIALOG_PORTRAIT_INPUT_WIDTH: f64 = 328.0;
222
223/// The opacity a disabled day/year cell's ink composites at (0.38) — the
224/// reference's `M3EColorUtils.withOpacity(scheme.onSurface, 0.38)` in
225/// `dayForegroundColor`/`yearForegroundColor`.
226pub const DISABLED_DAY_OPACITY: f32 = 0.38;
227
228/// The alpha a range highlight fills at (0.12) — `rangeHighlightColor`'s
229/// `withOpacity(scheme.primary, 0.12)`.
230pub const RANGE_HIGHLIGHT_ALPHA: f32 = 0.12;
231
232// ---- Enums (enums/m3e_date_picker_enums.dart) --------------------------------
233
234/// Which calendar sub-view is showing — the reference's `M3EDatePickerMode`.
235#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
236pub enum DatePickerMode {
237 /// The day grid for the displayed month. Default.
238 #[default]
239 Day,
240 /// The scrollable year grid.
241 Year,
242}
243
244impl DatePickerMode {
245 /// The other sub-view — what the sub-header's label toggles to.
246 pub fn toggled(self) -> Self {
247 match self {
248 DatePickerMode::Day => DatePickerMode::Year,
249 DatePickerMode::Year => DatePickerMode::Day,
250 }
251 }
252}
253
254/// How the dialog takes its date — the reference's `M3EDatePickerEntryMode`.
255#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
256pub enum DatePickerEntryMode {
257 /// Calendar, with an affordance to switch to text input. Default.
258 #[default]
259 Calendar,
260 /// Text input, with an affordance to switch to the calendar.
261 Input,
262 /// Calendar only — no switch affordance at all.
263 CalendarOnly,
264 /// Text input only — no switch affordance at all.
265 InputOnly,
266}
267
268impl DatePickerEntryMode {
269 /// Whether this mode shows the text field rather than the calendar.
270 pub fn is_input(self) -> bool {
271 matches!(
272 self,
273 DatePickerEntryMode::Input | DatePickerEntryMode::InputOnly
274 )
275 }
276
277 /// Whether the header offers a switch affordance — false for the two
278 /// `*Only` modes, which the reference gives no entry-mode button.
279 pub fn is_toggleable(self) -> bool {
280 matches!(
281 self,
282 DatePickerEntryMode::Calendar | DatePickerEntryMode::Input
283 )
284 }
285
286 /// The mode a tap on the switch affordance requests. A `*Only` mode has no
287 /// affordance and reports itself unchanged, so a caller that wires the
288 /// toggle unconditionally still cannot escape a locked mode — the
289 /// reference's `case calendarOnly: case inputOnly: break;`.
290 pub fn toggled(self) -> Self {
291 match self {
292 DatePickerEntryMode::Calendar => DatePickerEntryMode::Input,
293 DatePickerEntryMode::Input => DatePickerEntryMode::Calendar,
294 other => other,
295 }
296 }
297}
298
299// ---- Strings (models/m3e_calendar_labels.dart + MaterialLocalizations en_US) --
300
301/// Every user-visible string and the first-day-of-week index the date-picker
302/// family reads.
303///
304/// `Copy` and `&'static str`-valued, so a widget holds one by value with no
305/// allocation. Override by struct update:
306///
307/// ```
308/// use frust_material::DatePickerStrings;
309///
310/// const FRENCH: DatePickerStrings = DatePickerStrings {
311/// months: [
312/// "janvier", "février", "mars", "avril", "mai", "juin",
313/// "juillet", "août", "septembre", "octobre", "novembre", "décembre",
314/// ],
315/// first_day_of_week: 1, // Monday
316/// cancel_label: "Annuler",
317/// ..DatePickerStrings::ENGLISH
318/// };
319/// assert_eq!(FRENCH.months[0], "janvier");
320/// assert_eq!(FRENCH.confirm_label, "OK");
321/// ```
322///
323/// See the [module docs](self)' localization note for why this replaces the
324/// reference's `MaterialLocalizations` lookup, and what it does *not* cover
325/// (the `mm/dd/yyyy` input format).
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327pub struct DatePickerStrings {
328 /// Full month names, January first (`M3ECalendarLabels.months`).
329 pub months: [&'static str; 12],
330 /// Abbreviated month names, January first — the `MMM` field of
331 /// [`Self::medium_date`].
332 pub month_abbreviations: [&'static str; 12],
333 /// One-letter weekday headers, **Sunday first** regardless of
334 /// [`Self::first_day_of_week`] (`M3ECalendarLabels.weekdayInitials` /
335 /// `MaterialLocalizations.narrowWeekdays`).
336 pub weekday_initials: [&'static str; 7],
337 /// Abbreviated weekday names, **Sunday first** — the `EEE` field of
338 /// [`Self::medium_date`].
339 pub weekday_abbreviations: [&'static str; 7],
340 /// Which weekday a week starts on: `0` Sunday .. `6` Saturday
341 /// (`MaterialLocalizations.firstDayOfWeekIndex`).
342 pub first_day_of_week: u32,
343 /// The dialog header's help line (`datePickerHelpText`).
344 pub help_text: &'static str,
345 /// The dialog headline shown before any date is picked
346 /// (`unspecifiedDate`).
347 pub no_date_selected: &'static str,
348 /// The dialog's dismiss action (`cancelButtonLabel`).
349 pub cancel_label: &'static str,
350 /// The dialog's confirm action (`okButtonLabel`).
351 pub confirm_label: &'static str,
352 /// The input field's label (`dateInputLabel`).
353 pub field_label: &'static str,
354 /// The input field's supporting/hint line (`dateHelpText`).
355 pub field_hint: &'static str,
356 /// The error for text that is not a date at all
357 /// (`invalidDateFormatLabel`).
358 pub error_format: &'static str,
359 /// The error for a real date outside the selectable bounds
360 /// (`invalidDateRangeLabel`).
361 pub error_invalid: &'static str,
362 /// The previous-month affordance's accessibility label
363 /// (`previousMonthTooltip`).
364 pub previous_month: &'static str,
365 /// The next-month affordance's accessibility label (`nextMonthTooltip`).
366 pub next_month: &'static str,
367 /// The year-selector toggle's accessibility label
368 /// (`selectYearSemanticsLabel`).
369 pub select_year: &'static str,
370 /// The switch-to-input affordance's accessibility label
371 /// (`inputDateModeButtonLabel`).
372 pub input_mode: &'static str,
373 /// The switch-to-calendar affordance's accessibility label
374 /// (`calendarModeButtonLabel`).
375 pub calendar_mode: &'static str,
376}
377
378impl DatePickerStrings {
379 /// Flutter `MaterialLocalizations`' own `en_US` values — what the reference
380 /// resolves on an English device, and what
381 /// [`M3ECalendarLabels`](self)'s hard-coded fallback table already carries.
382 pub const ENGLISH: Self = Self {
383 months: [
384 "January",
385 "February",
386 "March",
387 "April",
388 "May",
389 "June",
390 "July",
391 "August",
392 "September",
393 "October",
394 "November",
395 "December",
396 ],
397 month_abbreviations: [
398 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
399 ],
400 weekday_initials: ["S", "M", "T", "W", "T", "F", "S"],
401 weekday_abbreviations: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
402 first_day_of_week: 0,
403 help_text: "Select date",
404 no_date_selected: "Date",
405 cancel_label: "Cancel",
406 confirm_label: "OK",
407 field_label: "Enter Date",
408 field_hint: "mm/dd/yyyy",
409 error_format: "Invalid format.",
410 error_invalid: "Out of range.",
411 previous_month: "Previous month",
412 next_month: "Next month",
413 select_year: "Select year",
414 input_mode: "Switch to text input mode",
415 calendar_mode: "Switch to calendar mode",
416 };
417
418 /// The header initial for grid column `column` (`0` = the leftmost), which
419 /// [`Self::first_day_of_week`] rotates — the reference's
420 /// `narrowWeekdays[(firstDayOfWeekIndex + i) % 7]`.
421 pub fn weekday_initial(&self, column: usize) -> &'static str {
422 self.weekday_initials[(self.first_day_of_week as usize + column) % DAYS_PER_WEEK]
423 }
424
425 /// `"March 2026"` — `MaterialLocalizations.formatMonthYear`.
426 pub fn month_year(&self, date: MaterialDate) -> String {
427 format!(
428 "{} {}",
429 self.months[(date.month() - 1) as usize],
430 date.year()
431 )
432 }
433
434 /// `"2026"` — `MaterialLocalizations.formatYear`.
435 pub fn year(&self, year: i32) -> String {
436 year.to_string()
437 }
438
439 /// `"Thu, Aug 20"` — `MaterialLocalizations.formatMediumDate`'s `en_US`
440 /// `EEE, MMM d` pattern, which is what the dialog headline shows.
441 pub fn medium_date(&self, date: MaterialDate) -> String {
442 format!(
443 "{}, {} {}",
444 self.weekday_abbreviations[date.weekday_index() as usize],
445 self.month_abbreviations[(date.month() - 1) as usize],
446 date.day()
447 )
448 }
449}
450
451impl Default for DatePickerStrings {
452 fn default() -> Self {
453 Self::ENGLISH
454 }
455}
456
457// ---- The controlled state ----------------------------------------------------
458
459/// The date picker's whole controlled state: what is selected, which month and
460/// sub-view the calendar shows, which entry mode the dialog is in, and the
461/// input field's draft text.
462///
463/// Held by the app, never by a widget — see the [module docs](self)' controlled
464/// note. Every `with_*`/`stepped_*`/`toggled_*` method below returns a **new**
465/// value rather than mutating, which is exactly the shape a widget's
466/// `on_change` callback reports and an app's handler assigns.
467#[derive(Clone, Debug, PartialEq, Eq)]
468pub struct DatePickerState {
469 /// The picked date, or `None` if nothing is picked yet.
470 pub selected: Option<MaterialDate>,
471 /// The month the day grid shows, normalized to its 1st.
472 pub displayed_month: MaterialDate,
473 /// Which calendar sub-view is showing.
474 pub mode: DatePickerMode,
475 /// Which entry mode the dialog is in (ignored by a bare
476 /// [`calendar_date_picker`], which is always a calendar).
477 pub entry_mode: DatePickerEntryMode,
478 /// The input field's current text. Kept in sync with [`Self::selected`] by
479 /// [`Self::with_selected`], and free-form while the user types.
480 pub input_text: String,
481}
482
483impl DatePickerState {
484 /// A fresh state: `initial` selected (or nothing), showing that date's
485 /// month — or `today`'s if nothing is selected — in the day sub-view and
486 /// the calendar entry mode.
487 ///
488 /// Mirrors `_M3ECalendarDatePickerState.initState`'s
489 /// `base = _selectedDate ?? _currentDate; _displayedMonth = getMonth(base)`.
490 pub fn new(initial: Option<MaterialDate>, today: MaterialDate) -> Self {
491 let base = initial.unwrap_or(today);
492 Self {
493 selected: initial,
494 displayed_month: base.first_of_month(),
495 mode: DatePickerMode::Day,
496 entry_mode: DatePickerEntryMode::Calendar,
497 input_text: initial
498 .map(MaterialDate::format_compact)
499 .unwrap_or_default(),
500 }
501 }
502
503 /// This state with `selected` picked: the date recorded, the displayed
504 /// month moved onto it, and [`Self::input_text`] refreshed to its compact
505 /// form so a later switch into input mode shows the picked date (the
506 /// reference's `_updateValueForSelectedDate`).
507 pub fn with_selected(&self, date: MaterialDate) -> Self {
508 Self {
509 selected: Some(date),
510 displayed_month: date.first_of_month(),
511 input_text: date.format_compact(),
512 ..self.clone()
513 }
514 }
515
516 /// This state with the displayed month replaced (normalized to its 1st).
517 /// The selection is untouched — paging past the selected month keeps it
518 /// selected, exactly as the reference's `PageView` does.
519 pub fn with_displayed_month(&self, month: MaterialDate) -> Self {
520 Self {
521 displayed_month: month.first_of_month(),
522 ..self.clone()
523 }
524 }
525
526 /// This state paged `delta` months, clamped so the displayed month never
527 /// leaves `first..=last`'s own months — the reference's page-count bound
528 /// (`page > 0` / `page < _monthCount - 1`).
529 pub fn stepped_month(&self, delta: i32, first: MaterialDate, last: MaterialDate) -> Self {
530 let low = first.first_of_month();
531 let high = last.first_of_month();
532 let next = self.displayed_month.add_months(delta).clamp_to(low, high);
533 self.with_displayed_month(next)
534 }
535
536 /// Whether paging by `delta` would actually move — what the prev/next
537 /// affordances enable off.
538 pub fn can_step_month(&self, delta: i32, first: MaterialDate, last: MaterialDate) -> bool {
539 self.stepped_month(delta, first, last).displayed_month != self.displayed_month
540 }
541
542 /// This state showing calendar sub-view `mode`.
543 pub fn with_mode(&self, mode: DatePickerMode) -> Self {
544 Self {
545 mode,
546 ..self.clone()
547 }
548 }
549
550 /// This state with the calendar sub-view toggled (day ⇄ year) — what the
551 /// sub-header's month/year label reports.
552 pub fn toggled_mode(&self) -> Self {
553 self.with_mode(self.mode.toggled())
554 }
555
556 /// This state in entry mode `entry_mode`.
557 pub fn with_entry_mode(&self, entry_mode: DatePickerEntryMode) -> Self {
558 Self {
559 entry_mode,
560 ..self.clone()
561 }
562 }
563
564 /// This state with the entry mode toggled (calendar ⇄ input), **preserving
565 /// the selection and its formatted text** — the reference's
566 /// `_handleEntryModeToggle`, whose `_formKey.currentState?.save()` on the
567 /// way out of input mode exists for exactly this round trip.
568 ///
569 /// A locked (`*Only`) entry mode reports itself unchanged.
570 pub fn toggled_entry_mode(&self) -> Self {
571 self.with_entry_mode(self.entry_mode.toggled())
572 }
573
574 /// This state with the input field's draft text replaced. The selection is
575 /// **not** touched — a widget parses the text itself and calls
576 /// [`Self::with_selected`] separately once it resolves to a selectable
577 /// date, mirroring the reference's split between `onChanged`
578 /// (`field.didChange`) and `onDateSubmitted`/`onDateSaved`.
579 pub fn with_input_text(&self, text: impl Into<String>) -> Self {
580 Self {
581 input_text: text.into(),
582 ..self.clone()
583 }
584 }
585
586 /// The date a tap on year cell `year` would select, before any
587 /// selectability predicate: the current selection's month/day carried onto
588 /// `year` (shortened where that month is shorter) and clamped into
589 /// `first..=last` — the reference's `_buildYearCell` `candidate`.
590 pub fn year_candidate(
591 &self,
592 year: i32,
593 first: MaterialDate,
594 last: MaterialDate,
595 ) -> MaterialDate {
596 let base = self.selected.unwrap_or(self.displayed_month);
597 MaterialDate::new(year, base.month(), base.day()).clamp_to(first, last)
598 }
599
600 /// This state after a tap on year cell `year`: back to the day sub-view,
601 /// showing (and selecting) [`Self::year_candidate`] — the reference's
602 /// `_handleYearChanged`.
603 ///
604 /// A caller with a selectability predicate checks
605 /// [`Self::year_candidate`] first and falls back to
606 /// `self.with_displayed_month(..).with_mode(Day)` when the candidate is
607 /// refused, which is what the reference's `if (isSelectable(clamped))`
608 /// guard does.
609 pub fn with_year(&self, year: i32, first: MaterialDate, last: MaterialDate) -> Self {
610 let candidate = self.year_candidate(year, first, last);
611 self.with_selected(candidate).with_mode(DatePickerMode::Day)
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 fn d(year: i32, month: u32, day: u32) -> MaterialDate {
620 MaterialDate::new(year, month, day)
621 }
622
623 const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
624 const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
625
626 // ---- constants -------------------------------------------------------
627
628 #[test]
629 fn the_ported_metrics_match_the_reference_constants() {
630 // m3e_date_picker_constants.dart / m3e_date_picker_theme.dart.
631 assert_eq!(DAYS_PER_WEEK, 7);
632 assert_eq!(SUB_HEADER_HEIGHT, 52.0);
633 assert_eq!(WEEKDAY_ROW_HEIGHT, 24.0);
634 assert_eq!(DAY_ROW_HEIGHT, 48.0);
635 assert_eq!(DAY_GRID_TOP_PADDING, 4.0);
636 assert_eq!(MAX_DAY_PICKER_ROW_COUNT, 6);
637 assert_eq!(MAX_DAY_PICKER_HEIGHT, 336.0);
638 assert_eq!(MONTH_NAV_BUTTONS_WIDTH, 108.0);
639 assert_eq!(YEAR_COLUMN_COUNT, 3);
640 assert_eq!(YEAR_GRID_PADDING, 16.0);
641 assert_eq!(YEAR_ROW_HEIGHT, 52.0);
642 assert_eq!(YEAR_ROW_SPACING, 8.0);
643 assert_eq!(CALENDAR_WIDTH, 328.0);
644 assert_eq!(CALENDAR_PADDING, 12.0);
645 assert_eq!(DAY_SIZE, 40.0);
646 assert_eq!(ARROW_ICON_SIZE, 24.0);
647 assert_eq!(ARROW_PADDING, 8.0);
648 assert_eq!(HEADER_PORTRAIT_HEIGHT, 120.0);
649 assert_eq!(ACTIONS_MIN_HEIGHT, 52.0);
650 assert_eq!(MONTH_SCROLL_DURATION, Duration::from_millis(200));
651 assert_eq!(DIALOG_PORTRAIT_CALENDAR_WIDTH, 360.0);
652 assert_eq!(DIALOG_PORTRAIT_INPUT_WIDTH, 328.0);
653 assert_eq!(DISABLED_DAY_OPACITY, 0.38);
654 assert_eq!(RANGE_HIGHLIGHT_ALPHA, 0.12);
655 }
656
657 // ---- enums -----------------------------------------------------------
658
659 #[test]
660 fn the_calendar_sub_view_toggles_both_ways() {
661 assert_eq!(DatePickerMode::default(), DatePickerMode::Day);
662 assert_eq!(DatePickerMode::Day.toggled(), DatePickerMode::Year);
663 assert_eq!(DatePickerMode::Year.toggled(), DatePickerMode::Day);
664 }
665
666 #[test]
667 fn only_the_unlocked_entry_modes_toggle() {
668 assert_eq!(
669 DatePickerEntryMode::default(),
670 DatePickerEntryMode::Calendar
671 );
672 assert_eq!(
673 DatePickerEntryMode::Calendar.toggled(),
674 DatePickerEntryMode::Input
675 );
676 assert_eq!(
677 DatePickerEntryMode::Input.toggled(),
678 DatePickerEntryMode::Calendar
679 );
680 // A `*Only` mode reports itself — the reference's `break`.
681 assert_eq!(
682 DatePickerEntryMode::CalendarOnly.toggled(),
683 DatePickerEntryMode::CalendarOnly
684 );
685 assert_eq!(
686 DatePickerEntryMode::InputOnly.toggled(),
687 DatePickerEntryMode::InputOnly
688 );
689
690 assert!(!DatePickerEntryMode::Calendar.is_input());
691 assert!(DatePickerEntryMode::Input.is_input());
692 assert!(!DatePickerEntryMode::CalendarOnly.is_input());
693 assert!(DatePickerEntryMode::InputOnly.is_input());
694
695 assert!(DatePickerEntryMode::Calendar.is_toggleable());
696 assert!(DatePickerEntryMode::Input.is_toggleable());
697 assert!(!DatePickerEntryMode::CalendarOnly.is_toggleable());
698 assert!(!DatePickerEntryMode::InputOnly.is_toggleable());
699 }
700
701 // ---- strings ---------------------------------------------------------
702
703 #[test]
704 fn the_weekday_header_rotates_with_the_first_day_of_week() {
705 let sunday = DatePickerStrings::ENGLISH;
706 let columns: Vec<&str> = (0..7).map(|c| sunday.weekday_initial(c)).collect();
707 assert_eq!(columns, ["S", "M", "T", "W", "T", "F", "S"]);
708
709 let monday = DatePickerStrings {
710 first_day_of_week: 1,
711 ..DatePickerStrings::ENGLISH
712 };
713 let columns: Vec<&str> = (0..7).map(|c| monday.weekday_initial(c)).collect();
714 assert_eq!(columns, ["M", "T", "W", "T", "F", "S", "S"]);
715 }
716
717 #[test]
718 fn the_label_formats_match_the_en_us_patterns() {
719 let s = DatePickerStrings::ENGLISH;
720 assert_eq!(s.month_year(d(2026, 3, 1)), "March 2026");
721 assert_eq!(s.month_year(d(2026, 12, 31)), "December 2026");
722 assert_eq!(s.year(2026), "2026");
723 // 2026-08-20 is a Thursday.
724 assert_eq!(s.medium_date(d(2026, 8, 20)), "Thu, Aug 20");
725 // 2026-01-01 is a Thursday too.
726 assert_eq!(s.medium_date(d(2026, 1, 1)), "Thu, Jan 1");
727 }
728
729 #[test]
730 fn an_override_keeps_every_untranslated_field() {
731 let french = DatePickerStrings {
732 months: [
733 "janvier",
734 "février",
735 "mars",
736 "avril",
737 "mai",
738 "juin",
739 "juillet",
740 "août",
741 "septembre",
742 "octobre",
743 "novembre",
744 "décembre",
745 ],
746 first_day_of_week: 1,
747 cancel_label: "Annuler",
748 ..DatePickerStrings::ENGLISH
749 };
750 assert_eq!(french.month_year(d(2026, 3, 1)), "mars 2026");
751 assert_eq!(french.cancel_label, "Annuler");
752 assert_eq!(
753 french.confirm_label,
754 DatePickerStrings::ENGLISH.confirm_label
755 );
756 assert_eq!(french.weekday_initial(0), "M");
757 }
758
759 // ---- state transitions ------------------------------------------------
760
761 #[test]
762 fn a_fresh_state_shows_the_selected_month_or_today() {
763 let today = d(2026, 8, 20);
764 let empty = DatePickerState::new(None, today);
765 assert_eq!(empty.selected, None);
766 assert_eq!(empty.displayed_month, d(2026, 8, 1));
767 assert_eq!(empty.mode, DatePickerMode::Day);
768 assert_eq!(empty.entry_mode, DatePickerEntryMode::Calendar);
769 assert_eq!(empty.input_text, "");
770
771 let seeded = DatePickerState::new(Some(d(2020, 2, 29)), today);
772 assert_eq!(seeded.selected, Some(d(2020, 2, 29)));
773 assert_eq!(seeded.displayed_month, d(2020, 2, 1));
774 assert_eq!(seeded.input_text, "02/29/2020");
775 }
776
777 #[test]
778 fn selecting_records_the_date_moves_the_month_and_refreshes_the_text() {
779 let state = DatePickerState::new(None, d(2026, 8, 20));
780 let next = state.with_selected(d(2027, 3, 5));
781 assert_eq!(next.selected, Some(d(2027, 3, 5)));
782 assert_eq!(next.displayed_month, d(2027, 3, 1));
783 assert_eq!(next.input_text, "03/05/2027");
784 // The original is untouched — every transition is pure.
785 assert_eq!(state.selected, None);
786 }
787
788 #[test]
789 fn month_paging_clamps_at_both_bounds() {
790 let state = DatePickerState::new(Some(d(2020, 1, 15)), d(2026, 8, 20));
791 assert_eq!(state.displayed_month, d(2020, 1, 1));
792
793 // Already at the first month: stepping back is a no-op.
794 assert!(!state.can_step_month(-1, FIRST(), LAST()));
795 assert_eq!(
796 state.stepped_month(-1, FIRST(), LAST()).displayed_month,
797 d(2020, 1, 1)
798 );
799
800 // Forward moves one month at a time, and the selection stays put.
801 assert!(state.can_step_month(1, FIRST(), LAST()));
802 let next = state.stepped_month(1, FIRST(), LAST());
803 assert_eq!(next.displayed_month, d(2020, 2, 1));
804 assert_eq!(next.selected, state.selected);
805
806 // A jump past the last month clamps onto it.
807 let end = state.stepped_month(10_000, FIRST(), LAST());
808 assert_eq!(end.displayed_month, d(2030, 12, 1));
809 assert!(!end.can_step_month(1, FIRST(), LAST()));
810 }
811
812 #[test]
813 fn the_sub_view_toggle_round_trips() {
814 let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
815 let year = state.toggled_mode();
816 assert_eq!(year.mode, DatePickerMode::Year);
817 assert_eq!(year.toggled_mode().mode, DatePickerMode::Day);
818 assert_eq!(year.selected, state.selected, "the toggle keeps the value");
819 }
820
821 #[test]
822 fn the_entry_mode_toggle_round_trips_the_value_and_its_text() {
823 let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
824 assert_eq!(state.input_text, "08/20/2026");
825
826 let input = state.toggled_entry_mode();
827 assert_eq!(input.entry_mode, DatePickerEntryMode::Input);
828 assert_eq!(input.selected, state.selected);
829 assert_eq!(input.input_text, state.input_text);
830
831 let back = input.toggled_entry_mode();
832 assert_eq!(back.entry_mode, DatePickerEntryMode::Calendar);
833 assert_eq!(back.selected, state.selected);
834 assert_eq!(back.input_text, state.input_text);
835 assert_eq!(back, state, "a full round trip is the identity");
836 }
837
838 #[test]
839 fn a_locked_entry_mode_cannot_be_toggled_out_of() {
840 let locked = DatePickerState::new(None, d(2026, 8, 20))
841 .with_entry_mode(DatePickerEntryMode::InputOnly);
842 assert_eq!(
843 locked.toggled_entry_mode().entry_mode,
844 DatePickerEntryMode::InputOnly
845 );
846 }
847
848 #[test]
849 fn typing_replaces_only_the_draft_text() {
850 let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
851 let typing = state.with_input_text("08/2");
852 assert_eq!(typing.input_text, "08/2");
853 assert_eq!(typing.selected, state.selected, "typing never picks a date");
854 assert_eq!(typing.displayed_month, state.displayed_month);
855 }
856
857 #[test]
858 fn a_year_cell_carries_the_selection_forward_and_returns_to_the_day_grid() {
859 let state = DatePickerState::new(Some(d(2026, 3, 31)), d(2026, 8, 20))
860 .with_mode(DatePickerMode::Year);
861 let next = state.with_year(2028, FIRST(), LAST());
862 assert_eq!(next.mode, DatePickerMode::Day);
863 assert_eq!(next.selected, Some(d(2028, 3, 31)));
864 assert_eq!(next.displayed_month, d(2028, 3, 1));
865 assert_eq!(next.input_text, "03/31/2028");
866 }
867
868 #[test]
869 fn a_year_candidate_shortens_a_too_long_day_and_clamps_to_the_bounds() {
870 // 29 February only exists in a leap year.
871 let leap = DatePickerState::new(Some(d(2024, 2, 29)), d(2026, 8, 20));
872 assert_eq!(leap.year_candidate(2025, FIRST(), LAST()), d(2025, 2, 28));
873 assert_eq!(leap.year_candidate(2028, FIRST(), LAST()), d(2028, 2, 29));
874
875 // A year cell outside the bounds clamps onto the nearest bound.
876 let narrow_first = d(2026, 6, 1);
877 let narrow_last = d(2026, 6, 30);
878 let state = DatePickerState::new(Some(d(2026, 6, 15)), d(2026, 6, 15));
879 assert_eq!(
880 state.year_candidate(2020, narrow_first, narrow_last),
881 narrow_first
882 );
883 assert_eq!(
884 state.year_candidate(2030, narrow_first, narrow_last),
885 narrow_last
886 );
887 }
888
889 #[test]
890 fn a_year_candidate_falls_back_to_the_displayed_month_with_no_selection() {
891 let state = DatePickerState::new(None, d(2026, 8, 20));
892 assert_eq!(state.displayed_month, d(2026, 8, 1));
893 assert_eq!(state.year_candidate(2029, FIRST(), LAST()), d(2029, 8, 1));
894 }
895}