1import {
2 DatePicker,
3 Host,
4 HStack,
5 Picker,
6 Popover,
7 RNHostView,
8 Spacer,
9 Text,
10 VStack,
11} from "@expo/ui/swift-ui";
12import {
13 accessibilityHidden,
14 clipped,
15 font,
16 datePickerStyle,
17 foregroundStyle,
18 frame,
19 labelsHidden,
20 padding,
21 pickerStyle,
22 tag,
23} from "@expo/ui/swift-ui/modifiers";
24import {
25 localSnoozeDate,
26 localSnoozeTime,
27 resolveCustomSnooze,
28 type CustomSnoozeInput,
29} from "@t3tools/client-runtime/state/thread-settled";
30import { useState, type ReactNode } from "react";
31import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
32import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
33import { ScrollView, useWindowDimensions, View } from "react-native";
34import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
35import { NativeHeaderToolbar } from "../../native/StackHeader";
36import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
37import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
38
39const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
40const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>();
41const modes = [
42 { value: "date", label: "Date and time" },
43 { value: "duration", label: "Duration" },
44] as const;
45const units = [
46 { value: "minutes", label: "Minutes" },
47 { value: "hours", label: "Hours" },
48 { value: "days", label: "Days" },
49] as const;
50
51export function CustomSnoozeSheet(props: {
52 readonly onClose: () => void;
53 readonly onSnooze: (snoozedUntil: string) => void;
54}) {
55 const { width, height } = useWindowDimensions();
56 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
57 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
58 const [amount, setAmount] = useState(2);
59 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
60 const [error, setError] = useState<string | null>(null);
61 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
62 const popoverWidth = Math.min(360, width - 32);
63 const popoverHeight = Math.min(error ? 364 : 324, height - 96);
64 const updateDate = (value: Date) => {
65 setDate(value);
66 setError(null);
67 };
68
69 const submit = () => {
70 const input: CustomSnoozeInput =
71 mode === "date"
72 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
73 : { mode, amount: String(amount), unit };
74 const snoozedUntil = resolveCustomSnooze(input, new Date());
75 if (!snoozedUntil) {
76 setError(
77 mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.",
78 );
79 return;
80 }
81 props.onSnooze(snoozedUntil);
82 props.onClose();
83 };
84
85 return (
86 <Host
87 style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
88 colorScheme={themeAppearance}
89 seedColor={colors["--color-primary"]}
90 >
91 <Popover
92 isPresented
93 onIsPresentedChange={(presented) => {
94 if (!presented) props.onClose();
95 }}
96 >
97 <Popover.Trigger>
98 <Spacer
99 modifiers={[frame({ maxWidth: Infinity, maxHeight: Infinity }), accessibilityHidden()]}
100 />
101 </Popover.Trigger>
102 <Popover.Content>
103 <SnoozePopoverNavigation
104 width={popoverWidth}
105 height={popoverHeight}
106 onClose={props.onClose}
107 onSubmit={submit}
108 >
109 <Host
110 matchContents={{ vertical: true }}
111 colorScheme={themeAppearance}
112 style={{ width: popoverWidth }}
113 >
114 <VStack
115 spacing={16}
116 modifiers={[padding({ all: 16 }), foregroundStyle(colors["--color-foreground"])]}
117 >
118 <Picker
119 label="Snooze mode"
120 selection={mode}
121 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
122 setMode(value);
123 setError(null);
124 }}
125 modifiers={[pickerStyle("segmented")]}
126 >
127 {modes.map((option) => (
128 <Text key={option.value} modifiers={[tag(option.value)]}>
129 {option.label}
130 </Text>
131 ))}
132 </Picker>
133 {mode === "date" ? (
134 <DatePicker
135 title="Snooze until"
136 selection={date}
137 displayedComponents={["date", "hourAndMinute"]}
138 onDateChange={updateDate}
139 modifiers={[
140 datePickerStyle("wheel"),
141 labelsHidden(),
142 frame({ maxWidth: Infinity, minHeight: 180, maxHeight: 180 }),
143 ]}
144 />
145 ) : (
146 <HStack spacing={0}>
147 <Picker
148 label="Duration amount"
149 selection={amount}
150 onSelectionChange={(value: number) => {
151 setAmount(value);
152 setError(null);
153 }}
154 modifiers={[
155 pickerStyle("wheel"),
156 labelsHidden(),
157 frame({ minWidth: 0, maxWidth: Infinity, minHeight: 180, maxHeight: 180 }),
158 clipped(),
159 ]}
160 >
161 {durationAmounts.map((value) => (
162 <Text
163 key={value}
164 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
165 >
166 {String(value)}
167 </Text>
168 ))}
169 </Picker>
170 <Picker
171 label="Duration unit"
172 selection={unit}
173 onSelectionChange={(value: typeof unit) => {
174 setUnit(value);
175 setError(null);
176 }}
177 modifiers={[
178 pickerStyle("wheel"),
179 labelsHidden(),
180 frame({ minWidth: 0, maxWidth: Infinity, minHeight: 180, maxHeight: 180 }),
181 clipped(),
182 ]}
183 >
184 {units.map((option) => (
185 <Text
186 key={option.value}
187 modifiers={[
188 tag(option.value),
189 foregroundStyle(colors["--color-foreground"]),
190 ]}
191 >
192 {option.label}
193 </Text>
194 ))}
195 </Picker>
196 </HStack>
197 )}
198 {error ? (
199 <Text
200 modifiers={[
201 font({ textStyle: "footnote" }),
202 foregroundStyle(colors["--color-danger-foreground"]),
203 ]}
204 >
205 {error}
206 </Text>
207 ) : null}
208 </VStack>
209 </Host>
210 </SnoozePopoverNavigation>
211 </Popover.Content>
212 </Popover>
213 </Host>
214 );
215}
216
217/** The popover owns a native navigation bar, just like the app's sheet screens. */
218function SnoozePopoverNavigation(props: {
219 readonly width: number;
220 readonly height: number;
221 readonly onClose: () => void;
222 readonly onSubmit: () => void;
223 readonly children: ReactNode;
224}) {
225 const navigationTheme = useMobileNavigationTheme();
226 const { themeVariables: colors } = useAppearancePreferences();
227
228 return (
229 <VStack modifiers={[frame({ width: props.width, height: props.height })]}>
230 <RNHostView>
231 <View style={{ width: props.width, height: props.height }}>
232 <NavigationIndependentTree>
233 <NavigationContainer theme={navigationTheme}>
234 <SnoozeStack.Navigator
235 screenOptions={{
236 contentStyle: { backgroundColor: "transparent" },
237 headerShadowVisible: false,
238 headerStyle: {
239 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED
240 ? "transparent"
241 : colors["--color-card"],
242 },
243 headerTintColor: colors["--color-foreground"],
244 headerTitleStyle: { fontSize: 17, fontWeight: "700" },
245 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
246 title: "Custom snooze",
247 }}
248 >
249 <SnoozeStack.Screen name="CustomSnooze">
250 {() => (
251 <>
252 <NativeHeaderToolbar placement="left">
253 <NativeHeaderToolbar.Button
254 accessibilityLabel="Cancel custom snooze"
255 icon="xmark"
256 onPress={props.onClose}
257 />
258 </NativeHeaderToolbar>
259 <NativeHeaderToolbar placement="right">
260 <NativeHeaderToolbar.Button label="Snooze" onPress={props.onSubmit} />
261 </NativeHeaderToolbar>
262 <ScrollView
263 contentInsetAdjustmentBehavior="automatic"
264 showsVerticalScrollIndicator={false}
265 >
266 {props.children}
267 </ScrollView>
268 </>
269 )}
270 </SnoozeStack.Screen>
271 </SnoozeStack.Navigator>
272 </NavigationContainer>
273 </NavigationIndependentTree>
274 </View>
275 </RNHostView>
276 </VStack>
277 );
278}