use std::{
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use derive_setters::Setters;
use tessera_ui::{
DimensionValue, Dp, Modifier, State, provide_context, remember, tessera, use_context,
};
use crate::{
alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
column::{ColumnArgs, column},
modifier::ModifierExt as _,
row::{RowArgs, row},
shape_def::Shape,
spacer::spacer,
surface::{SurfaceArgs, SurfaceStyle, surface},
text::{TextArgs, text},
theme::{ContentColor, MaterialTheme},
};
const TIME_CELL_WIDTH: Dp = Dp(72.0);
const TIME_CELL_HEIGHT: Dp = Dp(56.0);
const TIME_CELL_RADIUS: Dp = Dp(12.0);
const TIME_STEP_BUTTON_SIZE: Dp = Dp(28.0);
const PERIOD_BUTTON_WIDTH: Dp = Dp(56.0);
const PERIOD_BUTTON_HEIGHT: Dp = Dp(32.0);
const TIME_ROW_GAP: Dp = Dp(12.0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimePickerDisplayMode {
#[default]
Picker,
Input,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DayPeriod {
Am,
Pm,
}
pub struct TimePickerState {
hour: u8,
minute: u8,
is_24_hour: bool,
display_mode: TimePickerDisplayMode,
}
impl TimePickerState {
pub fn new(
initial_hour: u8,
initial_minute: u8,
is_24_hour: bool,
display_mode: TimePickerDisplayMode,
) -> Self {
Self {
hour: clamp_hour(initial_hour),
minute: clamp_minute(initial_minute),
is_24_hour,
display_mode,
}
}
pub fn hour(&self) -> u8 {
self.hour
}
pub fn minute(&self) -> u8 {
self.minute
}
pub fn is_24_hour(&self) -> bool {
self.is_24_hour
}
pub fn display_mode(&self) -> TimePickerDisplayMode {
self.display_mode
}
pub fn period(&self) -> DayPeriod {
if self.hour >= 12 {
DayPeriod::Pm
} else {
DayPeriod::Am
}
}
pub fn hour_for_display(&self) -> u8 {
if self.is_24_hour {
self.hour
} else {
let hour = self.hour % 12;
if hour == 0 { 12 } else { hour }
}
}
pub fn set_hour(&mut self, hour: u8) {
self.hour = clamp_hour(hour);
}
pub fn set_minute(&mut self, minute: u8) {
self.minute = clamp_minute(minute);
}
pub fn set_is_24_hour(&mut self, is_24_hour: bool) {
self.is_24_hour = is_24_hour;
}
pub fn set_display_mode(&mut self, mode: TimePickerDisplayMode) {
self.display_mode = mode;
}
pub fn toggle_display_mode(&mut self) {
self.display_mode = match self.display_mode {
TimePickerDisplayMode::Picker => TimePickerDisplayMode::Input,
TimePickerDisplayMode::Input => TimePickerDisplayMode::Picker,
};
}
pub fn set_period(&mut self, period: DayPeriod) {
if self.is_24_hour {
return;
}
let is_pm = self.hour >= 12;
match (period, is_pm) {
(DayPeriod::Am, true) => self.hour = self.hour.saturating_sub(12),
(DayPeriod::Pm, false) => self.hour = (self.hour + 12).min(23),
_ => {}
}
}
pub fn increment_hour(&mut self, step: u8) {
let step = normalize_step(step, 23);
self.hour = ((self.hour as u16 + step as u16) % 24) as u8;
}
pub fn decrement_hour(&mut self, step: u8) {
let step = normalize_step(step, 23) as i16;
let value = (self.hour as i16 - step).rem_euclid(24);
self.hour = value as u8;
}
pub fn increment_minute(&mut self, step: u8) {
let step = normalize_step(step, 59);
self.minute = ((self.minute as u16 + step as u16) % 60) as u8;
}
pub fn decrement_minute(&mut self, step: u8) {
let step = normalize_step(step, 59) as i16;
let value = (self.minute as i16 - step).rem_euclid(60);
self.minute = value as u8;
}
fn snapshot(&self) -> TimePickerSnapshot {
TimePickerSnapshot {
hour: self.hour,
minute: self.minute,
is_24_hour: self.is_24_hour,
display_mode: self.display_mode,
}
}
}
impl Default for TimePickerState {
fn default() -> Self {
let (hour, minute) = current_time_utc();
TimePickerState::new(hour, minute, false, TimePickerDisplayMode::Picker)
}
}
#[derive(Clone)]
struct TimePickerSnapshot {
hour: u8,
minute: u8,
is_24_hour: bool,
display_mode: TimePickerDisplayMode,
}
#[derive(Clone, Setters)]
pub struct TimePickerArgs {
pub modifier: Modifier,
pub initial_hour: u8,
pub initial_minute: u8,
pub is_24_hour: bool,
pub display_mode: TimePickerDisplayMode,
pub hour_step: u8,
pub minute_step: u8,
}
impl Default for TimePickerArgs {
fn default() -> Self {
let (hour, minute) = current_time_utc();
Self {
modifier: Modifier::new()
.constrain(Some(DimensionValue::WRAP), Some(DimensionValue::WRAP)),
initial_hour: hour,
initial_minute: minute,
is_24_hour: false,
display_mode: TimePickerDisplayMode::Picker,
hour_step: 1,
minute_step: 1,
}
}
}
#[derive(Setters)]
pub struct TimePickerDialogArgs {
#[setters(skip)]
pub state: State<TimePickerState>,
#[setters(strip_option, into)]
pub title: Option<String>,
#[setters(skip)]
pub confirm_button: Option<Arc<dyn Fn() + Send + Sync>>,
#[setters(skip)]
pub dismiss_button: Option<Arc<dyn Fn() + Send + Sync>>,
pub show_mode_toggle: bool,
pub picker_args: TimePickerArgs,
}
impl TimePickerDialogArgs {
pub fn new(state: State<TimePickerState>) -> Self {
Self {
state,
title: None,
confirm_button: None,
dismiss_button: None,
show_mode_toggle: false,
picker_args: TimePickerArgs::default(),
}
}
pub fn confirm_button<F>(mut self, f: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.confirm_button = Some(Arc::new(f));
self
}
pub fn confirm_button_shared(mut self, f: Arc<dyn Fn() + Send + Sync>) -> Self {
self.confirm_button = Some(f);
self
}
pub fn dismiss_button<F>(mut self, f: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.dismiss_button = Some(Arc::new(f));
self
}
pub fn dismiss_button_shared(mut self, f: Arc<dyn Fn() + Send + Sync>) -> Self {
self.dismiss_button = Some(f);
self
}
}
#[tessera]
pub fn time_picker(args: impl Into<TimePickerArgs>) {
let args: TimePickerArgs = args.into();
let initial_hour = args.initial_hour;
let initial_minute = args.initial_minute;
let is_24_hour = args.is_24_hour;
let display_mode = args.display_mode;
let state =
remember(|| TimePickerState::new(initial_hour, initial_minute, is_24_hour, display_mode));
time_picker_with_state(args, state);
}
#[tessera]
pub fn time_picker_with_state(args: impl Into<TimePickerArgs>, state: State<TimePickerState>) {
let args: TimePickerArgs = args.into();
let snapshot = state.with(|s| s.snapshot());
let theme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get();
let scheme = theme.color_scheme;
let typography = theme.typography;
let modifier = args.modifier;
let hour_step = normalize_step(args.hour_step, 23);
let minute_step = normalize_step(args.minute_step, 59);
let hour_display = format_two_digit(hour_for_display(snapshot.hour, snapshot.is_24_hour));
let minute_display = format_two_digit(snapshot.minute);
let show_labels = snapshot.display_mode == TimePickerDisplayMode::Input;
column(ColumnArgs::default().modifier(modifier), move |scope| {
scope.child(move || {
let hour_display = hour_display.clone();
let minute_display = minute_display.clone();
row(
RowArgs::default()
.main_axis_alignment(MainAxisAlignment::Center)
.cross_axis_alignment(CrossAxisAlignment::Center),
move |row_scope| {
let hour_display = hour_display.clone();
row_scope.child(move || {
time_stepper_column(
"Hour",
hour_display,
show_labels,
move || {
state.with_mut(|s| s.increment_hour(hour_step));
},
move || {
state.with_mut(|s| s.decrement_hour(hour_step));
},
);
});
row_scope.child(|| spacer(Modifier::new().width(Dp(6.0))));
row_scope.child(move || {
text(
TextArgs::default()
.text(":")
.size(typography.headline_small.font_size)
.color(scheme.on_surface_variant),
);
});
row_scope.child(|| spacer(Modifier::new().width(Dp(6.0))));
row_scope.child(move || {
let minute_display = minute_display.clone();
time_stepper_column(
"Minute",
minute_display,
show_labels,
move || {
state.with_mut(|s| s.increment_minute(minute_step));
},
move || {
state.with_mut(|s| s.decrement_minute(minute_step));
},
);
});
},
);
});
if !snapshot.is_24_hour {
scope.child(|| spacer(Modifier::new().height(TIME_ROW_GAP)));
let is_pm = snapshot.hour >= 12;
scope.child(move || {
period_toggle(is_pm, state);
});
}
});
}
#[tessera]
pub fn time_picker_dialog(args: impl Into<TimePickerDialogArgs>) {
let args: TimePickerDialogArgs = args.into();
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
let title = args.title;
let picker_args = args.picker_args;
let state = args.state;
let confirm_button = args.confirm_button;
let dismiss_button = args.dismiss_button;
let show_mode_toggle = args.show_mode_toggle;
let has_confirm = confirm_button.is_some();
let has_dismiss = dismiss_button.is_some();
column(
ColumnArgs::default().modifier(Modifier::new().constrain(
Some(DimensionValue::Wrap {
min: Some(Dp(280.0).into()),
max: Some(Dp(520.0).into()),
}),
Some(DimensionValue::WRAP),
)),
move |scope| {
scope.child(move || {
row(
RowArgs::default()
.modifier(Modifier::new().fill_max_width())
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.cross_axis_alignment(CrossAxisAlignment::Center),
move |row_scope| {
row_scope.child(move || {
let title_text = title.as_deref().unwrap_or("Select time");
text(
TextArgs::default()
.text(title_text)
.size(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.typography
.title_medium
.font_size,
)
.color(scheme.on_surface),
);
});
if show_mode_toggle {
row_scope.child(move || {
time_display_mode_toggle(state);
});
}
},
);
});
scope.child(|| spacer(Modifier::new().height(Dp(12.0))));
scope.child(move || {
time_picker_with_state(picker_args, state);
});
if has_confirm || has_dismiss {
scope.child(|| spacer(Modifier::new().height(Dp(16.0))));
let action_color = scheme.primary;
scope.child(move || {
provide_context(
|| ContentColor {
current: action_color,
},
|| {
row(
RowArgs::default()
.modifier(Modifier::new().fill_max_width())
.main_axis_alignment(MainAxisAlignment::End)
.cross_axis_alignment(CrossAxisAlignment::Center),
move |row_scope| {
if let Some(dismiss) = dismiss_button {
row_scope.child(move || dismiss());
}
if has_confirm && has_dismiss {
row_scope.child(|| spacer(Modifier::new().width(Dp(8.0))));
}
if let Some(confirm) = confirm_button {
row_scope.child(move || confirm());
}
},
);
},
);
});
}
},
);
}
fn time_stepper_column(
label: &'static str,
value: String,
show_label: bool,
on_increment: impl Fn() + Send + Sync + 'static,
on_decrement: impl Fn() + Send + Sync + 'static,
) {
let theme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get();
let scheme = theme.color_scheme;
let typography = theme.typography;
column(
ColumnArgs::default().cross_axis_alignment(CrossAxisAlignment::Center),
move |scope| {
scope.child(move || {
step_button("+", on_increment);
});
scope.child(|| spacer(Modifier::new().height(Dp(6.0))));
scope.child(move || {
time_value_cell(value);
});
scope.child(|| spacer(Modifier::new().height(Dp(6.0))));
scope.child(move || {
step_button("-", on_decrement);
});
if show_label {
scope.child(|| spacer(Modifier::new().height(Dp(6.0))));
scope.child(move || {
text(
TextArgs::default()
.text(label)
.size(typography.label_small.font_size)
.color(scheme.on_surface_variant),
);
});
}
},
);
}
fn time_value_cell(value: String) {
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
surface(
SurfaceArgs::default()
.modifier(
Modifier::new()
.width(TIME_CELL_WIDTH)
.height(TIME_CELL_HEIGHT),
)
.style(SurfaceStyle::Filled {
color: scheme.surface_container_high,
})
.shape(Shape::rounded_rectangle(TIME_CELL_RADIUS))
.content_alignment(Alignment::Center),
move || {
text(
TextArgs::default()
.text(value)
.size(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.typography
.headline_small
.font_size,
)
.color(scheme.on_surface),
);
},
);
}
fn step_button(label: &'static str, on_click: impl Fn() + Send + Sync + 'static) {
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
surface(
SurfaceArgs::default()
.modifier(Modifier::new().size(TIME_STEP_BUTTON_SIZE, TIME_STEP_BUTTON_SIZE))
.style(SurfaceStyle::Filled {
color: scheme.surface_container_low,
})
.shape(Shape::capsule())
.content_alignment(Alignment::Center)
.on_click(on_click),
move || {
text(
TextArgs::default()
.text(label)
.size(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.typography
.body_medium
.font_size,
)
.color(scheme.on_surface),
);
},
);
}
fn period_toggle(is_pm: bool, state: State<TimePickerState>) {
row(
RowArgs::default()
.main_axis_alignment(MainAxisAlignment::Center)
.cross_axis_alignment(CrossAxisAlignment::Center),
move |scope| {
scope.child(move || {
period_button("AM", !is_pm, DayPeriod::Am, state);
});
scope.child(|| spacer(Modifier::new().width(Dp(8.0))));
scope.child(move || {
period_button("PM", is_pm, DayPeriod::Pm, state);
});
},
);
}
fn period_button(
label: &'static str,
selected: bool,
period: DayPeriod,
state: State<TimePickerState>,
) {
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
let text_color = if selected {
scheme.on_primary
} else {
scheme.on_surface
};
let style = if selected {
SurfaceStyle::Filled {
color: scheme.primary,
}
} else {
SurfaceStyle::Filled {
color: scheme.surface_container_low,
}
};
surface(
SurfaceArgs::default()
.modifier(
Modifier::new()
.width(PERIOD_BUTTON_WIDTH)
.height(PERIOD_BUTTON_HEIGHT),
)
.style(style)
.shape(Shape::capsule())
.content_alignment(Alignment::Center)
.on_click(move || {
state.with_mut(|s| s.set_period(period));
}),
move || {
text(
TextArgs::default()
.text(label)
.size(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.typography
.label_medium
.font_size,
)
.color(text_color),
);
},
);
}
fn time_display_mode_toggle(state: State<TimePickerState>) {
let scheme = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme;
let label = state.with(|s| match s.display_mode() {
TimePickerDisplayMode::Picker => "Input",
TimePickerDisplayMode::Input => "Picker",
});
surface(
SurfaceArgs::default()
.modifier(Modifier::new().padding_all(Dp(4.0)))
.style(SurfaceStyle::Filled {
color: scheme.surface_container_high,
})
.shape(Shape::capsule())
.content_alignment(Alignment::Center)
.on_click(move || {
state.with_mut(|s| s.toggle_display_mode());
}),
move || {
text(
TextArgs::default()
.text(label)
.size(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.typography
.label_small
.font_size,
)
.color(scheme.primary),
);
},
);
}
fn format_two_digit(value: u8) -> String {
format!("{value:02}")
}
fn hour_for_display(hour: u8, is_24_hour: bool) -> u8 {
if is_24_hour {
hour
} else {
let hour = hour % 12;
if hour == 0 { 12 } else { hour }
}
}
fn normalize_step(step: u8, max: u8) -> u8 {
if step == 0 { 1 } else { step.min(max) }
}
fn clamp_hour(hour: u8) -> u8 {
hour.min(23)
}
fn clamp_minute(minute: u8) -> u8 {
minute.min(59)
}
fn current_time_utc() -> (u8, u8) {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let hour = ((secs / 3_600) % 24) as u8;
let minute = ((secs / 60) % 60) as u8;
(hour, minute)
}