1import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";
2import {
3 BasicAlertDialog,
4 Column,
5 DateTimePicker,
6 Host,
7 FilledTonalIconButton,
8 Row,
9 Shape,
10 Surface,
11 Text,
12 TextButton,
13} from "@expo/ui/jetpack-compose";
14import {
15 padding,
16 size,
17 width,
18 fillMaxWidth,
19 testID,
20 verticalScroll,
21} from "@expo/ui/jetpack-compose/modifiers";
22import {
23 localSnoozeDate,
24 localSnoozeTime,
25 resolveCustomSnooze,
26 type CustomSnoozeInput,
27} from "@t3tools/client-runtime/state/thread-settled";
28import { requireNativeModule } from "expo";
29import { useEffect, useState } from "react";
30import { AppState, useWindowDimensions } from "react-native";
31
32import { OverlayPortal } from "../../components/OverlayPortal";
33import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
34import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
35import type { CustomSnoozeSheet as SharedCustomSnoozeSheet } from "./CustomSnoozeSheet.shared";
36import {
37 applySnoozePickerDate,
38 applySnoozePickerTime,
39 snoozeDateToPickerDate,
40} from "./customSnoozeDate";
41
42type Props = Parameters<typeof SharedCustomSnoozeSheet>[0];
43
44const roundedCorner = Shape.RoundedCorner;
45
46const modes = [
47 { value: "date", label: "Date and time" },
48 { value: "duration", label: "Duration" },
49] as const;
50const units = [
51 { value: "minutes", label: "Minutes" },
52 { value: "hours", label: "Hours" },
53 { value: "days", label: "Days" },
54] as const;
55
56function systemUses24HourClock() {
57 return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat();
58}
59
60export function CustomSnoozeSheet(props: Props) {
61 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
62 const [is24Hour, setIs24Hour] = useState(systemUses24HourClock);
63 useEffect(() => {
64 const subscription = AppState.addEventListener("change", (state) => {
65 if (state === "active") setIs24Hour(systemUses24HourClock());
66 });
67 return () => subscription.remove();
68 }, []);
69 const titleTypography = useScaledTextRole("title");
70 const bodyTypography = useScaledTextRole("footnote");
71 const { width: windowWidth } = useWindowDimensions();
72 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
73 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
74 const [picker, setPicker] = useState<"date" | "time">("date");
75 const [amount, setAmount] = useState(2);
76 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
77 const [error, setError] = useState<string | null>(null);
78 const submit = () => {
79 const input: CustomSnoozeInput =
80 mode === "date"
81 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
82 : { mode, amount: String(amount), unit };
83 const snoozedUntil = resolveCustomSnooze(input, new Date());
84 if (!snoozedUntil) {
85 setError(
86 mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.",
87 );
88 return;
89 }
90 props.onSnooze(snoozedUntil);
91 props.onClose();
92 };
93 const pickerColors = {
94 containerColor: colors["--color-card-alt"],
95 titleContentColor: colors["--color-foreground-secondary"],
96 headlineContentColor: colors["--color-foreground"],
97 weekdayContentColor: colors["--color-foreground-secondary"],
98 subheadContentColor: colors["--color-foreground"],
99 navigationContentColor: colors["--color-foreground"],
100 yearContentColor: colors["--color-foreground"],
101 dayContentColor: colors["--color-foreground"],
102 currentYearContentColor: colors["--color-primary"],
103 selectedYearContainerColor: colors["--color-primary"],
104 selectedYearContentColor: colors["--color-primary-foreground"],
105 selectedDayContainerColor: colors["--color-primary"],
106 selectedDayContentColor: colors["--color-primary-foreground"],
107 todayContentColor: colors["--color-primary"],
108 todayDateBorderColor: colors["--color-primary"],
109 dividerColor: colors["--color-border"],
110 clockDialColor: colors["--color-secondary"],
111 clockDialSelectedContentColor: colors["--color-primary-foreground"],
112 clockDialUnselectedContentColor: colors["--color-secondary-foreground"],
113 selectorColor: colors["--color-primary"],
114 periodSelectorSelectedContainerColor: colors["--color-secondary"],
115 periodSelectorSelectedContentColor: colors["--color-secondary-foreground"],
116 periodSelectorUnselectedContentColor: colors["--color-foreground"],
117 timeSelectorSelectedContainerColor: colors["--color-secondary"],
118 timeSelectorSelectedContentColor: colors["--color-secondary-foreground"],
119 timeSelectorUnselectedContainerColor: colors["--color-card"],
120 timeSelectorUnselectedContentColor: colors["--color-foreground"],
121 };
122 return (
123 // Recycled thread rows can detach a zero-sized native dialog host.
124 <OverlayPortal>
125 <Host colorScheme={themeAppearance} style={{ height: 0, width: 0 }}>
126 <BasicAlertDialog
127 onDismissRequest={props.onClose}
128 properties={{ usePlatformDefaultWidth: false }}
129 modifiers={[width(Math.min(360, windowWidth - 32))]}
130 >
131 <Surface
132 color={colors["--color-card-alt"]}
133 contentColor={colors["--color-foreground"]}
134 shape={roundedCorner({
135 cornerRadii: { topStart: 28, topEnd: 28, bottomStart: 28, bottomEnd: 28 },
136 })}
137 >
138 <Column modifiers={[fillMaxWidth(), verticalScroll()]}>
139 <Column
140 verticalArrangement={{ spacedBy: 16 }}
141 modifiers={[fillMaxWidth(), padding(24, 24, 24, 16)]}
142 >
143 <Text style={titleTypography}>Custom snooze</Text>
144 <MaterialSegmentedButtons
145 options={modes}
146 selected={mode}
147 onSelect={(value) => {
148 setMode(value);
149 setError(null);
150 }}
151 />
152 {mode === "date" ? (
153 <MaterialSegmentedButtons
154 options={[
155 {
156 value: "date",
157 label: date.toLocaleDateString([], { month: "short", day: "numeric" }),
158 },
159 {
160 value: "time",
161 label: date.toLocaleTimeString([], {
162 hour: "numeric",
163 minute: "2-digit",
164 hourCycle: is24Hour ? "h23" : "h12",
165 }),
166 },
167 ]}
168 selected={picker}
169 onSelect={setPicker}
170 />
171 ) : null}
172 </Column>
173 {mode === "date" ? (
174 <Column horizontalAlignment="center" modifiers={[fillMaxWidth()]}>
175 <SnoozeDateTimePicker
176 key={picker}
177 date={date}
178 picker={picker}
179 is24Hour={is24Hour}
180 colors={pickerColors}
181 onChange={(selected) => {
182 setDate((current) =>
183 picker === "date"
184 ? applySnoozePickerDate(current, selected)
185 : applySnoozePickerTime(current, selected),
186 );
187 setError(null);
188 }}
189 />
190 </Column>
191 ) : (
192 <Column
193 verticalArrangement={{ spacedBy: 16 }}
194 modifiers={[fillMaxWidth(), padding(24, 8, 24, 16)]}
195 >
196 <Row
197 horizontalArrangement="spaceEvenly"
198 verticalAlignment="center"
199 modifiers={[fillMaxWidth()]}
200 >
201 <FilledTonalIconButton
202 enabled={amount > 1}
203 onClick={() => setAmount((current) => Math.max(1, current - 1))}
204 colors={{
205 containerColor: colors["--color-secondary"],
206 contentColor: colors["--color-secondary-foreground"],
207 }}
208 modifiers={[size(48, 48), testID("snooze-decrease-duration")]}
209 >
210 <Text style={titleTypography}>−</Text>
211 </FilledTonalIconButton>
212 <Text style={titleTypography} modifiers={[testID("snooze-duration")]}>
213 {String(amount)}
214 </Text>
215 <FilledTonalIconButton
216 enabled={amount < 99}
217 onClick={() => setAmount((current) => Math.min(99, current + 1))}
218 colors={{
219 containerColor: colors["--color-secondary"],
220 contentColor: colors["--color-secondary-foreground"],
221 }}
222 modifiers={[size(48, 48), testID("snooze-increase-duration")]}
223 >
224 <Text style={titleTypography}>+</Text>
225 </FilledTonalIconButton>
226 </Row>
227 <MaterialSegmentedButtons options={units} selected={unit} onSelect={setUnit} />
228 </Column>
229 )}
230 <Column
231 verticalArrangement={{ spacedBy: 16 }}
232 modifiers={[fillMaxWidth(), padding(24, 8, 24, 24)]}
233 >
234 {error ? (
235 <Text style={bodyTypography} color={colors["--color-danger-foreground"]}>
236 {error}
237 </Text>
238 ) : null}
239 <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}>
240 <TextButton
241 onClick={props.onClose}
242 colors={{ contentColor: colors["--color-foreground"] }}
243 >
244 <Text style={bodyTypography}>Cancel</Text>
245 </TextButton>
246 <TextButton
247 onClick={submit}
248 colors={{ contentColor: colors["--color-foreground"] }}
249 >
250 <Text style={bodyTypography}>Snooze</Text>
251 </TextButton>
252 </Row>
253 </Column>
254 </Column>
255 </Surface>
256 </BasicAlertDialog>
257 </Host>
258 </OverlayPortal>
259 );
260}
261
262function SnoozeDateTimePicker(props: {
263 readonly date: Date;
264 readonly picker: "date" | "time";
265 readonly is24Hour: boolean;
266 readonly colors: React.ComponentProps<typeof DateTimePicker>["elementColors"];
267 readonly onChange: (date: Date) => void;
268}) {
269 // Changing initialDate resets Compose's picker state, including its active clock dial.
270 const [initialDate] = useState(() =>
271 props.picker === "date" ? snoozeDateToPickerDate(props.date) : props.date.toISOString(),
272 );
273 return (
274 <DateTimePicker
275 initialDate={initialDate}
276 displayedComponents={props.picker === "date" ? "date" : "hourAndMinute"}
277 is24Hour={props.is24Hour}
278 showVariantToggle={false}
279 elementColors={props.colors}
280 onDateSelected={props.onChange}
281 />
282 );
283}