1import { useCallback, useEffect, useState } from "react";
2import { Platform, Modal, Pressable, TextInput, View } from "react-native";
3
4import { cn } from "../lib/cn";
5import { AppText } from "./AppText";
6import { MaterialConfirmDialog } from "./MaterialConfirmDialog";
7import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types";
8
9export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types";
10
11type DialogRequest =
12 | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
13 | { readonly kind: "text-input"; readonly request: TextInputDialogRequest };
14
15let presentRequest: ((request: DialogRequest) => void) | null = null;
16
17/**
18 * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
19 * match the app (and support per-button destructive red), so this is for
20 * Android, where the native dialog can only theme all confirm buttons at
21 * once. Requires ConfirmDialogHost to be mounted at the app root.
22 */
23export function showConfirmDialog(request: ConfirmDialogRequest): void {
24 presentRequest?.({ kind: "confirm", request });
25}
26
27export function showTextInputDialog(request: TextInputDialogRequest): void {
28 presentRequest?.({ kind: "text-input", request });
29}
30
31/**
32 * Android-style alert dialog matching the native one themed by
33 * withAndroidModernAlertDialog — left-aligned text, right-aligned text
34 * buttons — with what the native theme can't do: a per-dialog destructive
35 * button color and a dimmer message than the title.
36 */
37export function ConfirmDialogHost() {
38 const [presented, setPresented] = useState<DialogRequest | null>(null);
39 const [inputValue, setInputValue] = useState("");
40 useEffect(() => {
41 presentRequest = (request) => {
42 setInputValue(request.kind === "text-input" ? request.request.initialValue : "");
43 setPresented(request);
44 };
45 return () => {
46 presentRequest = null;
47 };
48 }, []);
49
50 const handleCancel = useCallback(() => {
51 presented?.request.onCancel?.();
52 setPresented(null);
53 }, [presented]);
54
55 const handleConfirm = useCallback(
56 (nativeInputValue?: string) => {
57 if (presented?.kind === "confirm") {
58 presented.request.onConfirm();
59 } else if (presented?.kind === "text-input") {
60 presented.request.onConfirm(nativeInputValue ?? inputValue);
61 }
62 setPresented(null);
63 },
64 [inputValue, presented],
65 );
66
67 const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;
68
69 if (Platform.OS === "android")
70 return presented ? (
71 <MaterialConfirmDialog
72 key={`${presented.kind}:${presented.request.title}:${presented.kind === "text-input" ? presented.request.initialValue : ""}`}
73 request={presented.request}
74 inputInitialValue={
75 presented.kind === "text-input" ? presented.request.initialValue : undefined
76 }
77 onInputChange={setInputValue}
78 confirmDisabled={confirmDisabled}
79 onCancel={handleCancel}
80 onConfirm={handleConfirm}
81 />
82 ) : null;
83
84 return (
85 <Modal
86 visible={presented !== null}
87 transparent
88 animationType="fade"
89 statusBarTranslucent
90 navigationBarTranslucent
91 onRequestClose={handleCancel}
92 >
93 {presented === null ? null : (
94 <View className="flex-1 items-center justify-center bg-backdrop px-8">
95 <View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
96 <AppText className="text-lg font-t3-medium">{presented.request.title}</AppText>
97 {presented.kind === "confirm" && presented.request.message !== undefined ? (
98 <AppText className="mt-2 text-sm text-foreground-secondary">
99 {presented.request.message}
100 </AppText>
101 ) : null}
102 {presented.kind === "text-input" ? (
103 <TextInput
104 accessibilityLabel={presented.request.title}
105 autoFocus
106 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
107 onChangeText={setInputValue}
108 onSubmitEditing={confirmDisabled ? undefined : () => handleConfirm()}
109 returnKeyType="done"
110 selectTextOnFocus
111 value={inputValue}
112 />
113 ) : null}
114 <View className="mt-5 flex-row justify-end gap-1">
115 <View className="overflow-hidden rounded-full">
116 <Pressable
117 accessibilityRole="button"
118 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
119 onPress={handleCancel}
120 >
121 <AppText className="text-base font-t3-medium">
122 {presented.request.cancelText ?? "Cancel"}
123 </AppText>
124 </Pressable>
125 </View>
126 <View className="overflow-hidden rounded-full">
127 <Pressable
128 accessibilityRole="button"
129 disabled={confirmDisabled}
130 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
131 onPress={() => handleConfirm()}
132 >
133 <AppText
134 className={cn(
135 "text-base font-t3-medium",
136 presented.kind === "confirm" &&
137 presented.request.destructive &&
138 "text-danger-foreground",
139 confirmDisabled && "text-foreground-tertiary",
140 )}
141 >
142 {presented.request.confirmText}
143 </AppText>
144 </Pressable>
145 </View>
146 </View>
147 </View>
148 </View>
149 )}
150 </Modal>
151 );
152}