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