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