| 1 | import { |
| 2 | DatePicker, |
| 3 | Host, |
| 4 | HStack, |
| 5 | Picker, |
| 6 | Popover, |
| 7 | RNHostView, |
| 8 | Spacer, |
| 9 | Text, |
| 10 | VStack, |
| 11 | } from "@expo/ui/swift-ui"; |
| 12 | import { |
| 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"; |
| 24 | import { |
| 25 | localSnoozeDate, |
| 26 | localSnoozeTime, |
| 27 | resolveCustomSnooze, |
| 28 | type CustomSnoozeInput, |
| 29 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 30 | import { useState, type ReactNode } from "react"; |
| 31 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; |
| 32 | import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator"; |
| 33 | import { ScrollView, useWindowDimensions, View } from "react-native"; |
| 34 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; |
| 35 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 36 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 37 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 38 | |
| 39 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); |
| 40 | const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>(); |
| 41 | const modes = [ |
| 42 | { value: "date", label: "Date and time" }, |
| 43 | { value: "duration", label: "Duration" }, |
| 44 | ] as const; |
| 45 | const units = [ |
| 46 | { value: "minutes", label: "Minutes" }, |
| 47 | { value: "hours", label: "Hours" }, |
| 48 | { value: "days", label: "Days" }, |
| 49 | ] as const; |
| 50 | |
| 51 | export 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. */ |
| 218 | function 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 | } |