Skip to main content

repose_material/material3/
date_picker.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4
5use repose_core::*;
6use repose_ui::{Box, Column, Row, Spacer, Text, TextStyle, ViewExt};
7
8use super::*;
9
10/// State for `DatePicker` - manages selected date.
11pub struct DatePickerState {
12    pub year: Signal<i32>,
13    pub month: Signal<u32>, // 1-12
14    pub day: Signal<u32>,
15}
16
17impl DatePickerState {
18    pub fn new(year: i32, month: u32, day: u32) -> Self {
19        Self {
20            year: signal(year),
21            month: signal(month.clamp(1, 12)),
22            day: signal(day.clamp(1, 31)),
23        }
24    }
25
26    pub fn selected_date(&self) -> (i32, u32, u32) {
27        (self.year.get(), self.month.get(), self.day.get())
28    }
29}
30
31fn days_in_month(year: i32, month: u32) -> u32 {
32    match month {
33        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
34        4 | 6 | 9 | 11 => 30,
35        2 => {
36            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
37                29
38            } else {
39                28
40            }
41        }
42        _ => 30,
43    }
44}
45
46/// Day of week for the first day of the given month/year.
47/// Returns 0=Mon ... 6=Sun using Zeller-like formula for Gregorian calendar.
48fn first_day_of_month(year: i32, month: u32) -> u32 {
49    let m = month as i32;
50    let (y, adj_m) = if m <= 2 {
51        (year - 1, m + 12)
52    } else {
53        (year, m)
54    };
55    let k = y % 100;
56    let j = y / 100;
57    let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
58    // Convert Zeller's Saturday=0 to Monday=0, Sunday=6
59    ((h + 5) % 7) as u32
60}
61
62/// Simple calendar date for today-highlighting in DatePicker.
63struct ReposeDate {
64    year: i32,
65    month: u32,
66    day: u32,
67}
68
69impl ReposeDate {
70    /// Compute today's date from the system clock.
71    fn now() -> Self {
72        let duration = web_time::SystemTime::now()
73            .duration_since(web_time::UNIX_EPOCH)
74            .unwrap_or_default();
75        let days = (duration.as_secs() / 86_400) as i64;
76        // Howard Hinnant's civil_from_days
77        let z = days + 719468;
78        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
79        let doe = (z - era * 146_097) as u64;
80        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
81        let y = (yoe as i64) + era * 400;
82        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
83        let mp = (5 * doy + 2) / 153;
84        let d = doy - (153 * mp + 2) / 5 + 1;
85        let m = if mp < 10 { mp + 3 } else { mp - 9 };
86        let y = if m <= 2 { y + 1 } else { y };
87        Self {
88            year: y as i32,
89            month: m as u32,
90            day: d as u32,
91        }
92    }
93}
94
95const MONTH_NAMES: [&str; 12] = [
96    "January",
97    "February",
98    "March",
99    "April",
100    "May",
101    "June",
102    "July",
103    "August",
104    "September",
105    "October",
106    "November",
107    "December",
108];
109
110const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
111
112/// Colors for [`DatePicker`].
113#[derive(Clone)]
114pub struct DatePickerColors {
115    pub container_color: Color,
116    pub header_color: Color,
117    pub weekday_color: Color,
118    pub day_color: Color,
119    pub selected_day_color: Color,
120    pub selected_day_container_color: Color,
121    pub today_content_color: Color,
122    pub today_border_color: Color,
123    pub navigation_color: Color,
124    pub year_selected_container_color: Color,
125    pub year_selected_content_color: Color,
126    pub year_unselected_content_color: Color,
127}
128
129impl Default for DatePickerColors {
130    fn default() -> Self {
131        Self {
132            container_color: DatePickerDefaults::container_color(),
133            header_color: DatePickerDefaults::header_color(),
134            weekday_color: DatePickerDefaults::weekday_color(),
135            day_color: DatePickerDefaults::day_color(),
136            selected_day_color: DatePickerDefaults::selected_day_color(),
137            selected_day_container_color: DatePickerDefaults::selected_day_container_color(),
138            today_content_color: DatePickerDefaults::today_content_color(),
139            today_border_color: DatePickerDefaults::today_border_color(),
140            navigation_color: DatePickerDefaults::header_color(),
141            year_selected_container_color: DatePickerDefaults::year_selected_container_color(),
142            year_selected_content_color: DatePickerDefaults::year_selected_content_color(),
143            year_unselected_content_color: DatePickerDefaults::year_unselected_content_color(),
144        }
145    }
146}
147
148/// Configuration for [`DatePicker`].
149#[derive(Clone)]
150pub struct DatePickerConfig {
151    pub modifier: Modifier,
152    pub colors: DatePickerColors,
153    pub show_mode_toggle: bool,
154}
155
156impl Default for DatePickerConfig {
157    fn default() -> Self {
158        Self {
159            modifier: Modifier::new(),
160            colors: DatePickerColors::default(),
161            show_mode_toggle: true,
162        }
163    }
164}
165
166/// M3 Date Picker dialog with month/year navigation, proper calendar grid,
167/// today indicator, and confirm/cancel actions.
168pub fn DatePicker(
169    state: Rc<DatePickerState>,
170    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
171    on_dismiss: Rc<dyn Fn()>,
172    config: DatePickerConfig,
173) -> View {
174    let th = theme();
175    let (year, month, day) = state.selected_date();
176    let dim = days_in_month(year, month);
177    let start_dow = first_day_of_month(year, month);
178
179    // Year step helpers
180    let prev_year = {
181        let s = state.clone();
182        move || {
183            s.year.set(s.year.get() - 1);
184            let d = days_in_month(s.year.get(), s.month.get());
185            if s.day.get() > d {
186                s.day.set(d);
187            }
188        }
189    };
190    let next_year = {
191        let s = state.clone();
192        move || {
193            s.year.set(s.year.get() + 1);
194            let d = days_in_month(s.year.get(), s.month.get());
195            if s.day.get() > d {
196                s.day.set(d);
197            }
198        }
199    };
200
201    let prev_month = {
202        let s = state.clone();
203        move || {
204            if s.month.get() == 1 {
205                s.year.set(s.year.get() - 1);
206                s.month.set(12);
207            } else {
208                s.month.set(s.month.get() - 1);
209            }
210            let d = days_in_month(s.year.get(), s.month.get());
211            if s.day.get() > d {
212                s.day.set(d);
213            }
214        }
215    };
216
217    let next_month = {
218        let s = state.clone();
219        move || {
220            if s.month.get() == 12 {
221                s.year.set(s.year.get() + 1);
222                s.month.set(1);
223            } else {
224                s.month.set(s.month.get() + 1);
225            }
226            let d = days_in_month(s.year.get(), s.month.get());
227            if s.day.get() > d {
228                s.day.set(d);
229            }
230        }
231    };
232
233    // Determine today for highlight
234    let now = ReposeDate::now();
235    let today = (now.year, now.month, now.day);
236
237    Column(config.modifier.padding(16.0)).child((
238        // Month header
239        Row(Modifier::new()
240            .fill_max_width()
241            .align_items(AlignItems::CENTER))
242        .child((
243            IconButton(
244                Box(Modifier::new())
245                    .child(Text("◀").color(config.colors.navigation_color).size(16.0)),
246                prev_month,
247                IconButtonConfig::default(),
248            ),
249            Spacer(),
250            Column(Modifier::new().align_items(AlignItems::CENTER)).child((
251                Text(MONTH_NAMES[(month - 1) as usize].to_string())
252                    .size(th.typography.title_medium)
253                    .color(config.colors.header_color),
254                Row(Modifier::new().gap(8.0).align_items(AlignItems::CENTER)).child((
255                    IconButton(
256                        Box(Modifier::new())
257                            .child(Text("‹").color(config.colors.navigation_color).size(14.0)),
258                        prev_year,
259                        IconButtonConfig::default(),
260                    ),
261                    Text(year.to_string())
262                        .size(th.typography.body_small)
263                        .color(th.on_surface_variant),
264                    IconButton(
265                        Box(Modifier::new())
266                            .child(Text("›").color(config.colors.navigation_color).size(14.0)),
267                        next_year,
268                        IconButtonConfig::default(),
269                    ),
270                )),
271            )),
272            Spacer(),
273            IconButton(
274                Box(Modifier::new())
275                    .child(Text("▶").color(config.colors.navigation_color).size(16.0)),
276                next_month,
277                IconButtonConfig::default(),
278            ),
279        )),
280        Box(Modifier::new().fill_max_width().height(12.0)),
281        // Day grid
282        Column(Modifier::new()).child({
283            let mut rows: Vec<View> = Vec::new();
284            // Day-of-week headers
285            let dow_headers: Vec<View> = DOW_HEADERS
286                .iter()
287                .map(|d| {
288                    Box(Modifier::new()
289                        .width(40.0)
290                        .height(40.0)
291                        .align_items(AlignItems::CENTER)
292                        .justify_content(JustifyContent::CENTER))
293                    .child(
294                        Text(d.to_string())
295                            .size(th.typography.label_small)
296                            .color(config.colors.weekday_color),
297                    )
298                })
299                .collect();
300            rows.push(Row(Modifier::new()).with_children(dow_headers));
301
302            // Proper calendar grid: offset by start_dow, 6 rows
303            let total_cells = start_dow + dim;
304            let num_rows = total_cells.div_ceil(7).min(6);
305            for w in 0..num_rows {
306                let mut week: Vec<View> = Vec::new();
307                for d in 0..7 {
308                    let cell_idx = w * 7 + d;
309                    if cell_idx < start_dow {
310                        week.push(Box(Modifier::new().width(40.0).height(40.0)));
311                    } else {
312                        let day_num = (cell_idx - start_dow + 1) as i32;
313                        if day_num <= dim as i32 {
314                            let is_selected = day_num == day as i32;
315                            let is_today =
316                                today.0 == year && today.1 == month && today.2 == day_num as u32;
317                            let s = state.clone();
318                            week.push(
319                                Box(Modifier::new()
320                                    .width(40.0)
321                                    .height(40.0)
322                                    .background(if is_selected {
323                                        config.colors.selected_day_container_color
324                                    } else {
325                                        Color::TRANSPARENT
326                                    })
327                                    .clip_rounded(20.0)
328                                    .align_items(AlignItems::CENTER)
329                                    .justify_content(JustifyContent::CENTER)
330                                    .clickable()
331                                    .on_click(move || {
332                                        s.day.set(day_num as u32);
333                                    }))
334                                .child({
335                                    let mut t = Text(day_num.to_string())
336                                        .size(th.typography.body_medium)
337                                        .color(if is_selected {
338                                            config.colors.selected_day_color
339                                        } else {
340                                            config.colors.day_color
341                                        });
342                                    if is_today && !is_selected {
343                                        t = t.modifier(Modifier::new().border(
344                                            1.0,
345                                            config.colors.today_border_color,
346                                            10.0,
347                                        ));
348                                    }
349                                    t
350                                }),
351                            );
352                        } else {
353                            week.push(Box(Modifier::new().width(40.0).height(40.0)));
354                        }
355                    }
356                }
357                rows.push(Row(Modifier::new()).with_children(week));
358            }
359            rows
360        }),
361        Box(Modifier::new().fill_max_width().height(12.0)),
362        // Cancel / Confirm
363        Row(Modifier::new()
364            .fill_max_width()
365            .justify_content(JustifyContent::END)
366            .gap(8.0))
367        .child((
368            TextButton(
369                Modifier::new(),
370                {
371                    let on_dismiss = on_dismiss.clone();
372                    move || (on_dismiss)()
373                },
374                ButtonConfig::default(),
375                || Text("Cancel").size(14.0),
376            ),
377            Button(
378                Modifier::new(),
379                {
380                    let on_confirm = on_confirm.clone();
381                    let s = state.clone();
382                    move || {
383                        let (y, m, d) = s.selected_date();
384                        on_confirm(y, m, d);
385                    }
386                },
387                ButtonConfig::default(),
388                || Text("OK").size(14.0),
389            ),
390        )),
391    ))
392}