Skip to content

Commit e207e6f

Browse files
committed
feat(mobile/ui): branded AppDialog replaces native Alert.alert
New AppDialog component and useAppDialog hook use a cream card + Geist type + green / destructive buttons so the dialog feels part of the Qibla design system instead of the platform alert sheet. All eight Alert.alert call sites (submit, edit, withdraw confirm, upload error, notifications-disabled x2, delete-account stub) route through the new dialog. Button gains a destructive variant for confirm-destructive flows. Also drops a stray aws4fetch entry from the root package.json that landed there during an earlier `bun add --filter` run.
1 parent eb60dbf commit e207e6f

9 files changed

Lines changed: 178 additions & 54 deletions

File tree

apps/mobile/components/ui/button.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const buttonVariants = cva(
1919
outline: "border border-line bg-white",
2020
ghost: "bg-white/10",
2121
subtle: "bg-green-tint",
22+
destructive: "bg-[#b04a3a]",
2223
},
2324
size: {
2425
md: "py-s-4",
@@ -43,6 +44,7 @@ const labelVariants = cva("font-sans-semibold", {
4344
outline: "text-ink",
4445
ghost: "text-white",
4546
subtle: "text-green",
47+
destructive: "text-white",
4648
},
4749
size: {
4850
md: "text-body",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useCallback, useState } from "react";
2+
import { Modal, Pressable, View } from "react-native";
3+
import { Button, type ButtonVariant } from "./button";
4+
import { Text } from "./text";
5+
6+
export type DialogAction = {
7+
label: string;
8+
variant?: ButtonVariant;
9+
onPress?: () => void | Promise<void>;
10+
};
11+
12+
export type DialogConfig = {
13+
title: string;
14+
body?: string;
15+
actions: DialogAction[];
16+
dismissOnBackdrop?: boolean;
17+
};
18+
19+
type AppDialogProps = DialogConfig & {
20+
visible: boolean;
21+
onClose: () => void;
22+
};
23+
24+
/**
25+
* Branded modal dialog matching the Qibla design system — cream card,
26+
* muted body text, green/destructive buttons. Drop-in replacement for
27+
* Alert.alert where we want the UI to feel part of the product.
28+
*/
29+
export function AppDialog({
30+
visible,
31+
onClose,
32+
title,
33+
body,
34+
actions,
35+
dismissOnBackdrop = true,
36+
}: AppDialogProps) {
37+
return (
38+
<Modal
39+
visible={visible}
40+
animationType="fade"
41+
transparent
42+
onRequestClose={onClose}
43+
statusBarTranslucent
44+
>
45+
<Pressable
46+
onPress={dismissOnBackdrop ? onClose : undefined}
47+
className="flex-1 items-center justify-center bg-black/50 px-s-6"
48+
>
49+
<Pressable
50+
// Stop the tap from bubbling to the backdrop and dismissing the
51+
// dialog when the user taps the card itself.
52+
onPress={() => {}}
53+
className="w-full rounded-md bg-cream p-s-5"
54+
style={{ maxWidth: 420 }}
55+
>
56+
<Text variant="display-sm">{title}</Text>
57+
{body ? (
58+
<Text variant="body" tone="muted" className="mt-s-2">
59+
{body}
60+
</Text>
61+
) : null}
62+
<View className="mt-s-5 gap-s-2">
63+
{actions.map((a) => (
64+
<Button
65+
key={a.label}
66+
label={a.label}
67+
variant={a.variant ?? "primary"}
68+
onPress={async () => {
69+
onClose();
70+
await a.onPress?.();
71+
}}
72+
/>
73+
))}
74+
</View>
75+
</Pressable>
76+
</Pressable>
77+
</Modal>
78+
);
79+
}
80+
81+
/**
82+
* Imperative-feeling API on top of <AppDialog>:
83+
* const dialog = useAppDialog();
84+
* dialog.show({ title, body, actions });
85+
* {dialog.element}
86+
*/
87+
export function useAppDialog() {
88+
const [config, setConfig] = useState<DialogConfig | null>(null);
89+
90+
const show = useCallback((cfg: DialogConfig) => setConfig(cfg), []);
91+
const hide = useCallback(() => setConfig(null), []);
92+
93+
const element = config ? (
94+
<AppDialog visible onClose={hide} {...config} />
95+
) : null;
96+
97+
return { show, hide, element };
98+
}

apps/mobile/features/settings/components/account-screen.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { router } from "expo-router";
22
import { StatusBar } from "expo-status-bar";
3-
import { Alert, Pressable, ScrollView, View } from "react-native";
3+
import { Pressable, ScrollView, View } from "react-native";
4+
import { useAppDialog } from "@/components/ui/dialog";
45
import { Text } from "@/components/ui/text";
56
import { signOut, useSession } from "@/lib/auth";
67
import { SettingsHeader } from "./settings-header";
@@ -20,6 +21,7 @@ function formatJoinDate(input: Date | string | undefined) {
2021
export function AccountScreen() {
2122
const { data: session } = useSession();
2223
const user = session?.user;
24+
const dialog = useAppDialog();
2325

2426
const onSignOut = async () => {
2527
try {
@@ -30,11 +32,11 @@ export function AccountScreen() {
3032
};
3133

3234
const onDeleteAccount = () => {
33-
Alert.alert(
34-
"Delete account",
35-
"Account deletion is coming soon. For now, contact support via the repo's issue tracker and we'll remove your data.",
36-
[{ text: "OK", style: "default" }],
37-
);
35+
dialog.show({
36+
title: "Delete account",
37+
body: "Account deletion is coming soon. For now, contact support via the repo's issue tracker and we'll remove your data.",
38+
actions: [{ label: "OK" }],
39+
});
3840
};
3941

4042
return (
@@ -80,6 +82,8 @@ export function AccountScreen() {
8082
</Text>
8183
</Pressable>
8284
</ScrollView>
85+
86+
{dialog.element}
8387
</View>
8488
);
8589
}

apps/mobile/features/settings/components/email-preferences-screen.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as Linking from "expo-linking";
22
import { StatusBar } from "expo-status-bar";
3-
import { Alert, ScrollView, View } from "react-native";
3+
import { ScrollView, View } from "react-native";
4+
import { useAppDialog } from "@/components/ui/dialog";
45
import { Text } from "@/components/ui/text";
56
import {
67
useHydrateSettings,
@@ -18,6 +19,7 @@ export function EmailPreferencesScreen() {
1819
useHydrateSettings();
1920
const prayerReminders = useSettingsStore((s) => s.prayerReminders);
2021
const setPrayerReminders = useSettingsStore((s) => s.setPrayerReminders);
22+
const dialog = useAppDialog();
2123

2224
const onTogglePrayerReminders = async (next: boolean) => {
2325
if (!next) {
@@ -27,14 +29,14 @@ export function EmailPreferencesScreen() {
2729
}
2830
const granted = await ensureNotificationPermission();
2931
if (!granted) {
30-
Alert.alert(
31-
"Notifications disabled",
32-
"Enable notifications in Settings to get prayer reminders.",
33-
[
34-
{ text: "Not now", style: "cancel" },
35-
{ text: "Open Settings", onPress: () => Linking.openSettings() },
32+
dialog.show({
33+
title: "Notifications disabled",
34+
body: "Enable notifications in Settings to get prayer reminders.",
35+
actions: [
36+
{ label: "Not now", variant: "outline" },
37+
{ label: "Open Settings", onPress: () => Linking.openSettings() },
3638
],
37-
);
39+
});
3840
return;
3941
}
4042
setPrayerReminders(true);
@@ -71,6 +73,8 @@ export function EmailPreferencesScreen() {
7173
</Text>
7274
</View>
7375
</ScrollView>
76+
77+
{dialog.element}
7478
</View>
7579
);
7680
}

apps/mobile/features/settings/components/settings-screen.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as Location from "expo-location";
33
import { router } from "expo-router";
44
import { StatusBar } from "expo-status-bar";
55
import { useEffect, useState } from "react";
6-
import { Alert, Pressable, ScrollView, Share, View } from "react-native";
6+
import { Pressable, ScrollView, Share, View } from "react-native";
7+
import { useAppDialog } from "@/components/ui/dialog";
78
import { Text } from "@/components/ui/text";
89
import { signOut, useSession } from "@/lib/auth";
910
import {
@@ -24,6 +25,7 @@ export function SettingsScreen() {
2425
const { data: session } = useSession();
2526
const prayerReminders = useSettingsStore((s) => s.prayerReminders);
2627
const setPrayerReminders = useSettingsStore((s) => s.setPrayerReminders);
28+
const dialog = useAppDialog();
2729

2830
const [locationStatus, setLocationStatus] = useState<
2931
"granted" | "denied" | "unknown"
@@ -45,14 +47,14 @@ export function SettingsScreen() {
4547
}
4648
const granted = await ensureNotificationPermission();
4749
if (!granted) {
48-
Alert.alert(
49-
"Notifications disabled",
50-
"Enable notifications in Settings to get prayer reminders.",
51-
[
52-
{ text: "Not now", style: "cancel" },
53-
{ text: "Open Settings", onPress: () => Linking.openSettings() },
50+
dialog.show({
51+
title: "Notifications disabled",
52+
body: "Enable notifications in Settings to get prayer reminders.",
53+
actions: [
54+
{ label: "Not now", variant: "outline" },
55+
{ label: "Open Settings", onPress: () => Linking.openSettings() },
5456
],
55-
);
57+
});
5658
return;
5759
}
5860
setPrayerReminders(true);
@@ -163,6 +165,8 @@ export function SettingsScreen() {
163165
</Text>
164166
</View>
165167
</ScrollView>
168+
169+
{dialog.element}
166170
</View>
167171
);
168172
}

apps/mobile/features/submissions/components/photo-manager-modal.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Image } from "expo-image";
22
import { useState } from "react";
33
import {
44
ActivityIndicator,
5-
Alert,
65
Dimensions,
76
Modal,
87
Pressable,
@@ -11,6 +10,7 @@ import {
1110
} from "react-native";
1211
import { SafeAreaView } from "react-native-safe-area-context";
1312
import { Button } from "@/components/ui/button";
13+
import { useAppDialog } from "@/components/ui/dialog";
1414
import { Icon } from "@/components/ui/icon";
1515
import { IconButton } from "@/components/ui/icon-button";
1616
import { Text } from "@/components/ui/text";
@@ -39,6 +39,7 @@ export function PhotoManagerModal({
3939
}: Props) {
4040
const upload = usePickAndUploadPhotos();
4141
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
42+
const dialog = useAppDialog();
4243
const remaining = maxPhotos - photos.length;
4344
const addDisabled = remaining <= 0 || upload.isPending;
4445

@@ -53,10 +54,11 @@ export function PhotoManagerModal({
5354
const next = [...photos, ...urls].slice(0, maxPhotos);
5455
onChange(next);
5556
} catch (err) {
56-
Alert.alert(
57-
"Upload failed",
58-
err instanceof Error ? err.message : "Try again.",
59-
);
57+
dialog.show({
58+
title: "Upload failed",
59+
body: err instanceof Error ? err.message : "Try again.",
60+
actions: [{ label: "OK" }],
61+
});
6062
}
6163
};
6264

@@ -116,7 +118,7 @@ export function PhotoManagerModal({
116118
<View className="mt-s-4 flex-row items-center justify-center gap-s-2">
117119
<ActivityIndicator color="#2e5d45" />
118120
<Text variant="caption" tone="muted">
119-
Uploading to R2
121+
Uploading photos
120122
</Text>
121123
</View>
122124
) : null}
@@ -161,6 +163,8 @@ export function PhotoManagerModal({
161163
url={previewUrl}
162164
onClose={() => setPreviewUrl(null)}
163165
/>
166+
167+
{dialog.element}
164168
</View>
165169
</Modal>
166170
);

apps/mobile/features/submissions/components/submission-edit-screen.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { useForm, useStore } from "@tanstack/react-form";
22
import { router, useLocalSearchParams } from "expo-router";
33
import { StatusBar } from "expo-status-bar";
44
import { useEffect, useState } from "react";
5-
import { Alert, ScrollView, View } from "react-native";
5+
import { ScrollView, View } from "react-native";
66
import { SafeAreaView } from "react-native-safe-area-context";
77
import { Button } from "@/components/ui/button";
8+
import { useAppDialog } from "@/components/ui/dialog";
89
import { IconButton } from "@/components/ui/icon-button";
910
import { Text } from "@/components/ui/text";
1011
import {
@@ -25,6 +26,7 @@ export function SubmissionEditScreen() {
2526
const update = useUpdateMySubmission();
2627
const del = useDeleteMySubmission();
2728
const [errorMessage, setErrorMessage] = useState<string | null>(null);
29+
const dialog = useAppDialog();
2830

2931
const form = useForm({
3032
defaultValues: EMPTY_SUBMISSION,
@@ -36,9 +38,11 @@ export function SubmissionEditScreen() {
3638
id,
3739
...(value as MosqueSubmissionInput),
3840
});
39-
Alert.alert("Saved", "Your changes are live.", [
40-
{ text: "OK", onPress: () => router.back() },
41-
]);
41+
dialog.show({
42+
title: "Saved",
43+
body: "Your changes are live.",
44+
actions: [{ label: "Done", onPress: () => router.back() }],
45+
});
4246
} catch (err) {
4347
setErrorMessage(
4448
err instanceof Error ? err.message : "Could not save changes",
@@ -75,28 +79,29 @@ export function SubmissionEditScreen() {
7579

7680
const handleDelete = () => {
7781
if (!id) return;
78-
Alert.alert(
79-
"Withdraw submission?",
80-
"This removes the pending row. You can submit again later.",
81-
[
82-
{ text: "Cancel", style: "cancel" },
82+
dialog.show({
83+
title: "Withdraw submission?",
84+
body: "This removes the pending row. You can submit again later.",
85+
actions: [
86+
{ label: "Cancel", variant: "outline" },
8387
{
84-
text: "Withdraw",
85-
style: "destructive",
88+
label: "Withdraw",
89+
variant: "destructive",
8690
onPress: async () => {
8791
try {
8892
await del.mutateAsync(id);
8993
router.back();
9094
} catch (err) {
91-
Alert.alert(
92-
"Could not withdraw",
93-
err instanceof Error ? err.message : "Try again.",
94-
);
95+
dialog.show({
96+
title: "Could not withdraw",
97+
body: err instanceof Error ? err.message : "Try again.",
98+
actions: [{ label: "OK" }],
99+
});
95100
}
96101
},
97102
},
98103
],
99-
);
104+
});
100105
};
101106

102107
return (
@@ -174,6 +179,8 @@ export function SubmissionEditScreen() {
174179
) : null}
175180
</ScrollView>
176181
)}
182+
183+
{dialog.element}
177184
</View>
178185
);
179186
}

0 commit comments

Comments
 (0)