diff --git a/__tests__/components/ConfirmDialog.test.tsx b/__tests__/components/ConfirmDialog.test.tsx
new file mode 100644
index 0000000..03a1256
--- /dev/null
+++ b/__tests__/components/ConfirmDialog.test.tsx
@@ -0,0 +1,68 @@
+/**
+ * Tests for the cross-platform ConfirmProvider / useConfirm — confirms that the
+ * dialog renders on demand and resolves true/false (unlike Alert.alert, which is
+ * a no-op on web).
+ */
+import React from "react";
+import { Text } from "react-native";
+import { render, fireEvent, waitFor, act } from "@testing-library/react-native";
+
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (k: string) => (k === "common.confirm" ? "Confirm" : k === "common.cancel" ? "Cancel" : k),
+ }),
+}));
+
+import { ConfirmProvider, useConfirm } from "~/components/ui/ConfirmDialog";
+
+function Harness({ onResult }: { onResult: (v: boolean) => void }) {
+ const confirm = useConfirm();
+ return (
+ {
+ const ok = await confirm({ title: "Delete?", message: "Sure?", confirmLabel: "Delete" });
+ onResult(ok);
+ }}
+ >
+ trigger
+
+ );
+}
+
+function renderWithProvider(onResult: (v: boolean) => void) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("useConfirm", () => {
+ it("resolves true when the confirm button is pressed", async () => {
+ const onResult = jest.fn();
+ const { getByText } = renderWithProvider(onResult);
+
+ act(() => {
+ fireEvent.press(getByText("trigger"));
+ });
+
+ // Dialog appears with our title + custom confirm label.
+ await waitFor(() => expect(getByText("Delete?")).toBeTruthy());
+ fireEvent.press(getByText("Delete"));
+
+ await waitFor(() => expect(onResult).toHaveBeenCalledWith(true));
+ });
+
+ it("resolves false when cancelled", async () => {
+ const onResult = jest.fn();
+ const { getByText } = renderWithProvider(onResult);
+
+ act(() => {
+ fireEvent.press(getByText("trigger"));
+ });
+ await waitFor(() => expect(getByText("Sure?")).toBeTruthy());
+ fireEvent.press(getByText("Cancel"));
+
+ await waitFor(() => expect(onResult).toHaveBeenCalledWith(false));
+ });
+});
diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx
index e11b9a3..a1ef7e8 100644
--- a/app/(app)/[appName]/[objectName]/[id].tsx
+++ b/app/(app)/[appName]/[objectName]/[id].tsx
@@ -1,5 +1,4 @@
import { SafeAreaView } from "react-native-safe-area-context";
-import { Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useClient, useQuery, useView } from "@objectstack/client-react";
import { useTranslation } from "react-i18next";
@@ -16,6 +15,7 @@ import {
} from "~/hooks/useStateMachines";
import { RecordStateMachines } from "~/components/workflow/RecordStateMachines";
import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
import { isActionVisible } from "~/lib/record-actions";
import { renderRecordTitle } from "~/lib/record-title";
@@ -28,6 +28,8 @@ export default function ObjectDetailScreen() {
const client = useClient();
const router = useRouter();
const { t } = useTranslation();
+ const { toastSuccess, toastError } = useToast();
+ const confirm = useConfirm();
const { data: viewData } = useView(objectName!, "form");
const { meta, fields } = useObjectMeta(objectName);
@@ -95,23 +97,21 @@ export default function ObjectDetailScreen() {
currentIndex >= 0 ? `${currentIndex + 1} of ${recordIds.length}` : undefined;
/* ---- Delete handler ---- */
- const handleDelete = useCallback(() => {
- Alert.alert(t("records.deleteRecord"), t("records.deleteConfirm"), [
- { text: t("common.cancel"), style: "cancel" },
- {
- text: t("common.delete"),
- style: "destructive",
- onPress: async () => {
- try {
- await client.data.delete(objectName!, id!);
- router.back();
- } catch {
- Alert.alert(t("common.error"), t("records.deleteFailed"));
- }
- },
- },
- ]);
- }, [client, objectName, id, router, t]);
+ const handleDelete = useCallback(async () => {
+ const ok = await confirm({
+ title: t("records.deleteRecord"),
+ message: t("records.deleteConfirm"),
+ confirmLabel: t("common.delete"),
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await client.data.delete(objectName!, id!);
+ router.back();
+ } catch {
+ toastError(t("records.deleteFailed"));
+ }
+ }, [confirm, client, objectName, id, router, t, toastError]);
/* ---- Object actions (record_header inline, record_more overflow) ---- */
const allActions = useMemo(
@@ -140,40 +140,32 @@ export default function ObjectDetailScreen() {
/* ---- Lifecycle / state machine diagram(s) ---- */
const stateMachines = useRecordStateMachines(meta, fields, record);
- const { toastSuccess, toastError } = useToast();
const [pendingEvent, setPendingEvent] = useState(null);
const handleTransition = useCallback(
- (machine: RecordStateMachine, transition: SMTransition) => {
+ async (machine: RecordStateMachine, transition: SMTransition) => {
if (!machine.field || !objectName || !id) return;
const toLabel =
machine.states.find((s) => s.name === transition.to)?.label ?? transition.to;
- Alert.alert(
- t("workflow.updateStatus"),
- t("workflow.moveToConfirm", { state: toLabel }),
- [
- { text: t("common.cancel"), style: "cancel" },
- {
- text: t("common.confirm"),
- onPress: async () => {
- setPendingEvent(`${machine.key}:${transition.event}`);
- try {
- await client.data.update(objectName, id, {
- [machine.field!]: transition.to,
- });
- await fetchRecord();
- toastSuccess(t("workflow.statusUpdated"));
- } catch {
- toastError(t("workflow.statusUpdateFailed"));
- } finally {
- setPendingEvent(null);
- }
- },
- },
- ],
- );
+ const ok = await confirm({
+ title: t("workflow.updateStatus"),
+ message: t("workflow.moveToConfirm", { state: toLabel }),
+ });
+ if (!ok) return;
+ setPendingEvent(`${machine.key}:${transition.event}`);
+ try {
+ await client.data.update(objectName, id, {
+ [machine.field]: transition.to,
+ });
+ await fetchRecord();
+ toastSuccess(t("workflow.statusUpdated"));
+ } catch {
+ toastError(t("workflow.statusUpdateFailed"));
+ } finally {
+ setPendingEvent(null);
+ }
},
- [client, objectName, id, t, fetchRecord, toastSuccess, toastError],
+ [confirm, client, objectName, id, t, fetchRecord, toastSuccess, toastError],
);
return (
diff --git a/app/(app)/[appName]/[objectName]/index.tsx b/app/(app)/[appName]/[objectName]/index.tsx
index 66ee2ee..867f056 100644
--- a/app/(app)/[appName]/[objectName]/index.tsx
+++ b/app/(app)/[appName]/[objectName]/index.tsx
@@ -1,5 +1,5 @@
import { SafeAreaView } from "react-native-safe-area-context";
-import { Alert, Pressable } from "react-native";
+import { Pressable } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Plus } from "lucide-react-native";
import { useClient, useQuery, useView } from "@objectstack/client-react";
@@ -8,6 +8,8 @@ import { useCallback, useState } from "react";
import { ListViewRenderer } from "~/components/renderers";
import type { ListViewMeta } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
import { useObjectMeta } from "~/hooks/useObjectMeta";
export default function ObjectListScreen() {
@@ -18,6 +20,8 @@ export default function ObjectListScreen() {
const client = useClient();
const router = useRouter();
const { t } = useTranslation();
+ const { toastError } = useToast();
+ const confirm = useConfirm();
const { data: viewData, isLoading: viewLoading } = useView(objectName!, "list");
const { meta, fields } = useObjectMeta(objectName);
@@ -49,26 +53,24 @@ export default function ObjectListScreen() {
);
const handleSwipeDelete = useCallback(
- (record: Record) => {
+ async (record: Record) => {
const id = (record.id ?? record._id) as string;
const label = (record.name ?? record.label ?? record.title ?? id) as string;
- Alert.alert(t("records.deleteRecord"), t("records.deleteConfirmNamed", { label }), [
- { text: t("common.cancel"), style: "cancel" },
- {
- text: t("common.delete"),
- style: "destructive",
- onPress: async () => {
- try {
- await client.data.delete(objectName!, id);
- refetch();
- } catch {
- Alert.alert(t("common.error"), t("records.deleteFailed"));
- }
- },
- },
- ]);
+ const ok = await confirm({
+ title: t("records.deleteRecord"),
+ message: t("records.deleteConfirmNamed", { label }),
+ confirmLabel: t("common.delete"),
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await client.data.delete(objectName!, id);
+ refetch();
+ } catch {
+ toastError(t("records.deleteFailed"));
+ }
},
- [client, objectName, refetch, t],
+ [confirm, client, objectName, refetch, t, toastError],
);
const handleFilterChange = useCallback((f: unknown) => {
diff --git a/app/(app)/packages.tsx b/app/(app)/packages.tsx
index 6b143c9..df63e0b 100644
--- a/app/(app)/packages.tsx
+++ b/app/(app)/packages.tsx
@@ -4,7 +4,6 @@ import {
Text,
ScrollView,
TouchableOpacity,
- Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Package, ToggleLeft, ToggleRight, Trash2 } from "lucide-react-native";
@@ -13,6 +12,7 @@ import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
/**
* Package management screen – list, enable, disable, uninstall packages.
@@ -22,6 +22,7 @@ import { ListSkeleton } from "~/components/ui/ListSkeleton";
export default function PackagesScreen() {
const { packages, isLoading, error, refetch, enable, disable, uninstall } =
usePackageManagement();
+ const confirm = useConfirm();
const handleToggle = async (id: string, enabled: boolean) => {
try {
@@ -35,25 +36,19 @@ export default function PackagesScreen() {
}
};
- const handleUninstall = (id: string, name: string) => {
- Alert.alert(
- "Uninstall Package",
- `Are you sure you want to uninstall "${name}"?`,
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Uninstall",
- style: "destructive",
- onPress: async () => {
- try {
- await uninstall(id);
- } catch {
- // Error is already set in the hook
- }
- },
- },
- ],
- );
+ const handleUninstall = async (id: string, name: string) => {
+ const ok = await confirm({
+ title: "Uninstall Package",
+ message: `Are you sure you want to uninstall "${name}"?`,
+ confirmLabel: "Uninstall",
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await uninstall(id);
+ } catch {
+ // Error is already set in the hook
+ }
};
return (
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index 1f42d40..168fc9f 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -1,4 +1,4 @@
-import { View, Text, ScrollView, TouchableOpacity, Alert } from "react-native";
+import { View, Text, ScrollView, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import {
UserCircle,
@@ -10,6 +10,8 @@ import {
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { authClient } from "~/lib/auth-client";
+import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
interface MenuItemProps {
icon: React.ReactNode;
@@ -51,21 +53,26 @@ function SectionHeader({ title }: { title: string }) {
export default function MoreScreen() {
const { data: session } = authClient.useSession();
const router = useRouter();
+ const { toastError } = useToast();
+ const confirm = useConfirm();
const performSignOut = async () => {
try {
await authClient.signOut();
router.replace("/(auth)/sign-in");
} catch {
- Alert.alert("Error", "Failed to sign out. Please try again.");
+ toastError("Failed to sign out. Please try again.");
}
};
- const handleSignOut = () => {
- Alert.alert("Sign Out", "Are you sure you want to sign out?", [
- { text: "Cancel", style: "cancel" },
- { text: "Sign Out", style: "destructive", onPress: () => void performSignOut() },
- ]);
+ const handleSignOut = async () => {
+ const ok = await confirm({
+ title: "Sign Out",
+ message: "Are you sure you want to sign out?",
+ confirmLabel: "Sign Out",
+ destructive: true,
+ });
+ if (ok) void performSignOut();
};
return (
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index 522a61c..ba420b2 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -1,28 +1,35 @@
-import { View, Text, ScrollView, Alert } from "react-native";
+import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { UserCircle } from "lucide-react-native";
import { useRouter } from "expo-router";
import { Button } from "~/components/ui/Button";
import { authClient } from "~/lib/auth-client";
+import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
export default function ProfileScreen() {
const { data: session } = authClient.useSession();
const router = useRouter();
+ const { toastError } = useToast();
+ const confirm = useConfirm();
const performSignOut = async () => {
try {
await authClient.signOut();
router.replace("/(auth)/sign-in");
} catch {
- Alert.alert("Error", "Failed to sign out. Please try again.");
+ toastError("Failed to sign out. Please try again.");
}
};
- const handleSignOut = () => {
- Alert.alert("Sign Out", "Are you sure you want to sign out?", [
- { text: "Cancel", style: "cancel" },
- { text: "Sign Out", style: "destructive", onPress: () => void performSignOut() },
- ]);
+ const handleSignOut = async () => {
+ const ok = await confirm({
+ title: "Sign Out",
+ message: "Are you sure you want to sign out?",
+ confirmLabel: "Sign Out",
+ destructive: true,
+ });
+ if (ok) void performSignOut();
};
return (
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 0d30a7a..d61913a 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -13,6 +13,7 @@ import { createObjectStackClient } from "~/lib/objectstack";
import { useServerStore } from "~/stores/server-store";
import { usePushNotifications } from "~/hooks/usePushNotifications";
import { ToastProvider } from "~/components/ui/Toast";
+import { ConfirmProvider } from "~/components/ui/ConfirmDialog";
const queryClient = new QueryClient();
@@ -98,15 +99,17 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/account.tsx b/app/account.tsx
index 3174001..be51239 100644
--- a/app/account.tsx
+++ b/app/account.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { View, Text, ScrollView, Alert } from "react-native";
+import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { authClient } from "~/lib/auth-client";
import { useAccount } from "~/hooks/useAccount";
@@ -7,6 +7,7 @@ import { useTwoFactor } from "~/hooks/useTwoFactor";
import { Input } from "~/components/ui/Input";
import { Button } from "~/components/ui/Button";
import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { useToast } from "~/components/ui/Toast";
function Field({
label,
@@ -58,9 +59,10 @@ export default function AccountScreen() {
const [tfBackupCodes, setTfBackupCodes] = useState([]);
const [tfCode, setTfCode] = useState("");
- const notify = (msg: string) => Alert.alert("Account", msg);
+ const { toastSuccess, toastError } = useToast();
+ const notify = (msg: string) => toastSuccess(msg);
const fail = (e: unknown) =>
- Alert.alert("Account", e instanceof Error ? e.message : "Something went wrong");
+ toastError(e instanceof Error ? e.message : "Something went wrong");
const onSaveName = async () => {
if (!name.trim()) return;
diff --git a/components/renderers/fields/FileField.tsx b/components/renderers/fields/FileField.tsx
index 81d912b..5f8959f 100644
--- a/components/renderers/fields/FileField.tsx
+++ b/components/renderers/fields/FileField.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from "react";
-import { View, Text, Pressable, ActivityIndicator, Alert } from "react-native";
+import { View, Text, Pressable, ActivityIndicator } from "react-native";
import {
Camera,
Upload,
@@ -11,6 +11,7 @@ import {
} from "lucide-react-native";
import type { FieldDefinition } from "../types";
import type { FileUploadResult } from "~/hooks/useFileUpload";
+import { useToast } from "~/components/ui/Toast";
/* ------------------------------------------------------------------ */
/* Types */
@@ -80,6 +81,7 @@ export function FileField({
error,
}: FileFieldProps) {
const [isUploading, setIsUploading] = useState(false);
+ const { toastError } = useToast();
const fileInfo = resolveFileInfo(value);
const handleUpload = useCallback(
@@ -92,12 +94,12 @@ export function FileField({
onChange?.(result);
}
} catch {
- Alert.alert("Upload Failed", "Could not upload the file. Please try again.");
+ toastError("Could not upload the file. Please try again.");
} finally {
setIsUploading(false);
}
},
- [onChange],
+ [onChange, toastError],
);
/* ---- Read-only ---- */
diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..b35ed12
--- /dev/null
+++ b/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,103 @@
+import React from "react";
+import { View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Dialog } from "./Dialog";
+import { Button } from "./Button";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface ConfirmOptions {
+ title: string;
+ /** Body text below the title. */
+ message?: string;
+ /** Confirm button label. Defaults to `common.confirm`. */
+ confirmLabel?: string;
+ /** Cancel button label. Defaults to `common.cancel`. */
+ cancelLabel?: string;
+ /** Style the confirm button as destructive (e.g. delete). */
+ destructive?: boolean;
+}
+
+interface ConfirmContextValue {
+ /** Open a confirm dialog; resolves `true` if confirmed, `false` otherwise. */
+ confirm: (options: ConfirmOptions) => Promise;
+}
+
+const ConfirmContext = React.createContext(null);
+
+/* ------------------------------------------------------------------ */
+/* Provider */
+/* ------------------------------------------------------------------ */
+
+interface PendingConfirm extends ConfirmOptions {
+ resolve: (value: boolean) => void;
+}
+
+/**
+ * Cross-platform confirm dialog. Unlike `Alert.alert` (which renders nothing on
+ * React Native Web), this is backed by the `Dialog`/`Modal` primitives and
+ * works identically on web and native. Expose via {@link useConfirm}.
+ */
+export function ConfirmProvider({ children }: { children: React.ReactNode }) {
+ const { t } = useTranslation();
+ const [pending, setPending] = React.useState(null);
+
+ const confirm = React.useCallback(
+ (options: ConfirmOptions) =>
+ new Promise((resolve) => {
+ setPending({ ...options, resolve });
+ }),
+ [],
+ );
+
+ const settle = React.useCallback(
+ (value: boolean) => {
+ setPending((curr) => {
+ curr?.resolve(value);
+ return null;
+ });
+ },
+ [],
+ );
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+export function useConfirm(): ConfirmContextValue["confirm"] {
+ const ctx = React.useContext(ConfirmContext);
+ if (!ctx) {
+ throw new Error("useConfirm must be used within a ");
+ }
+ return ctx.confirm;
+}
diff --git a/hooks/useRecordActions.tsx b/hooks/useRecordActions.tsx
index 72888b6..eea4ad7 100644
--- a/hooks/useRecordActions.tsx
+++ b/hooks/useRecordActions.tsx
@@ -7,7 +7,7 @@
* per-button spinners, and `modals` JSX the screen must render once.
*/
import React from "react";
-import { Alert, Modal, ScrollView, Text, TextInput, View } from "react-native";
+import { Modal, ScrollView, Text, TextInput, View } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import type { ObjectStackClient } from "@objectstack/client";
@@ -17,6 +17,7 @@ import { Select } from "~/components/ui/Select";
import { Switch } from "~/components/ui/Switch";
import { DatePicker } from "~/components/ui/DatePicker";
import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
import type { ActionMeta, ActionParamMeta } from "~/components/renderers/types";
import { runRecordAction, type ActionRunContext } from "~/lib/record-actions";
@@ -80,15 +81,10 @@ export function useRecordActions({
const [paramValues, setParamValues] = React.useState>({});
const [resultState, setResultState] = React.useState(null);
+ const showConfirm = useConfirm();
const confirm = React.useCallback(
- (message: string) =>
- new Promise((resolve) => {
- Alert.alert("", message, [
- { text: t("common.cancel"), style: "cancel", onPress: () => resolve(false) },
- { text: t("common.ok"), onPress: () => resolve(true) },
- ]);
- }),
- [t],
+ (message: string) => showConfirm({ title: t("common.confirm"), message }),
+ [showConfirm, t],
);
const collectParams = React.useCallback(
@@ -162,12 +158,12 @@ export function useRecordActions({
(p) => p.required && (paramValues[p.name] == null || paramValues[p.name] === ""),
);
if (missing) {
- Alert.alert("", t("actions.required", { field: missing.label }));
+ toastError(t("actions.required", { field: missing.label }));
return;
}
setParamState(null);
state.resolve(paramValues);
- }, [paramState, paramValues, t]);
+ }, [paramState, paramValues, t, toastError]);
const cancelParams = React.useCallback(() => {
const state = paramState;