From 796b617b595520704b2eff616abf6e401a18258f Mon Sep 17 00:00:00 2001
From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Date: Sun, 31 May 2026 07:59:16 +0800
Subject: [PATCH 1/2] feat(mobile): polish UX across all screens, renderers,
and UI primitives
Bring the whole app up to top-tier mobile-app UX standards:
- Design system: Button loading state + active scale + haptics; Input
left/right slots + focus/error states; new EmptyState, ListSkeleton,
and PressableCard primitives.
- Screens: skeleton loaders, badged empty/error states with retry,
press feedback + a11y across auth, tabs, account, and (app) routes.
- View renderers: replaced full-screen spinners with context-appropriate
skeletons (list/kanban/calendar/timeline/map/gantt/chart/report/page/
gallery/image), badged EmptyState for empty/error, press feedback on
rows/cards, swipe-action a11y.
- UI primitives: haptics + press feedback + a11y on Switch, Checkbox,
Select, MultiSelect, Tabs, DatePicker; BottomSheet/Toast a11y;
SearchBar clear button; FAB haptics.
- Auth: stamp Origin header from client (RN lacks @better-auth/expo
plugin) to fix "Miss origin" login error.
---
.../[appName]/[objectName]/[id]/edit.tsx | 26 ++--
app/(app)/[appName]/index.tsx | 62 ++++----
app/(app)/packages.tsx | 48 +++---
app/(app)/page/[id].tsx | 12 +-
app/(auth)/server-config.tsx | 59 ++++---
app/(auth)/sign-in.tsx | 91 +++++++----
app/(auth)/sign-up.tsx | 82 +++++++---
app/(tabs)/apps.tsx | 144 ++++++++----------
app/(tabs)/index.tsx | 90 +++++------
app/(tabs)/more.tsx | 11 +-
app/(tabs)/notifications.tsx | 33 ++--
app/(tabs)/profile.tsx | 9 +-
app/(tabs)/search.tsx | 37 ++---
app/account.tsx | 26 ++--
components/common/FloatingActionButton.tsx | 5 +-
components/common/LanguageSelector.tsx | 13 +-
components/common/SearchBar.tsx | 24 ++-
components/renderers/CalendarViewRenderer.tsx | 28 +++-
components/renderers/ChartViewRenderer.tsx | 41 ++++-
.../renderers/DashboardViewRenderer.tsx | 32 +++-
components/renderers/DetailViewRenderer.tsx | 75 ++++++---
components/renderers/FormViewRenderer.tsx | 14 +-
components/renderers/GalleryViewRenderer.tsx | 19 +++
components/renderers/GanttViewRenderer.tsx | 27 ++++
components/renderers/ImageGallery.tsx | 24 ++-
components/renderers/KanbanViewRenderer.tsx | 29 +++-
components/renderers/ListViewRenderer.tsx | 41 +++--
components/renderers/MapViewRenderer.tsx | 7 +-
components/renderers/PageRenderer.tsx | 34 ++++-
components/renderers/ReportRenderer.tsx | 45 ++++--
components/renderers/SwipeableRow.tsx | 8 +-
components/renderers/TimelineViewRenderer.tsx | 7 +-
components/ui/BottomSheet.tsx | 2 +
components/ui/Button.tsx | 24 ++-
components/ui/Checkbox.tsx | 15 +-
components/ui/DatePicker.tsx | 8 +-
components/ui/EmptyState.tsx | 64 ++++++++
components/ui/Input.tsx | 18 ++-
components/ui/ListSkeleton.tsx | 32 ++++
components/ui/MultiSelect.tsx | 10 +-
components/ui/PressableCard.tsx | 47 ++++++
components/ui/Select.tsx | 8 +-
components/ui/Switch.tsx | 13 +-
components/ui/Tabs.tsx | 12 +-
components/ui/Toast.tsx | 16 +-
lib/auth-client.ts | 45 ++++++
lib/objectstack.ts | 27 +++-
47 files changed, 1079 insertions(+), 465 deletions(-)
create mode 100644 components/ui/EmptyState.tsx
create mode 100644 components/ui/ListSkeleton.tsx
create mode 100644 components/ui/PressableCard.tsx
diff --git a/app/(app)/[appName]/[objectName]/[id]/edit.tsx b/app/(app)/[appName]/[objectName]/[id]/edit.tsx
index 0db0141..ea39aa7 100644
--- a/app/(app)/[appName]/[objectName]/[id]/edit.tsx
+++ b/app/(app)/[appName]/[objectName]/[id]/edit.tsx
@@ -1,10 +1,13 @@
import { SafeAreaView } from "react-native-safe-area-context";
-import { View, Text, ActivityIndicator, Pressable } from "react-native";
+import { View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useClient, useMutation } from "@objectstack/client-react";
import { useEffect, useState, useCallback } from "react";
+import { AlertCircle } from "lucide-react-native";
import { FormViewRenderer } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useObjectMeta } from "~/hooks/useObjectMeta";
export default function EditRecordScreen() {
@@ -55,8 +58,8 @@ export default function EditRecordScreen() {
return (
-
-
+
+
);
@@ -66,15 +69,14 @@ export default function EditRecordScreen() {
return (
-
- {loadError}
-
- Retry
-
-
+
);
}
diff --git a/app/(app)/[appName]/index.tsx b/app/(app)/[appName]/index.tsx
index 6421ca4..927f1d1 100644
--- a/app/(app)/[appName]/index.tsx
+++ b/app/(app)/[appName]/index.tsx
@@ -1,8 +1,10 @@
-import { View, Text, ScrollView, Pressable, ActivityIndicator, Linking } from "react-native";
+import { View, Text, ScrollView, Linking } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
-import { Inbox, ChevronRight } from "lucide-react-native";
-import { Card, CardContent } from "~/components/ui/Card";
+import { Inbox, ChevronRight, AlertCircle } from "lucide-react-native";
+import { PressableCard } from "~/components/ui/PressableCard";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { useApp, type NavigationItem } from "~/hooks/useApps";
import { getIcon } from "~/lib/getIcon";
@@ -59,24 +61,23 @@ export default function AppHomeScreen() {
const Icon = getIcon(item.icon);
const navigable = isNavigable(item);
return (
- navigate(item)}
- className={navigable ? "" : "opacity-40"}
+ className={`flex-row items-center p-3.5 ${navigable ? "" : "opacity-40"}`}
+ accessibilityRole={navigable ? "button" : undefined}
+ accessibilityLabel={item.label}
>
-
-
-
-
-
-
- {item.label}
-
- {navigable ? : null}
-
-
-
+
+
+
+
+ {item.label}
+
+ {navigable ? : null}
+
);
};
@@ -105,22 +106,25 @@ export default function AppHomeScreen() {
{isLoading ? (
-
-
+
+
) : error ? (
-
- {error.message}
+
+
) : navigation.length === 0 ? (
-
-
-
-
- No Navigation
-
- This app hasn't published a navigation menu yet.
-
+
+
) : (
diff --git a/app/(app)/packages.tsx b/app/(app)/packages.tsx
index e93e577..6b143c9 100644
--- a/app/(app)/packages.tsx
+++ b/app/(app)/packages.tsx
@@ -4,7 +4,6 @@ import {
Text,
ScrollView,
TouchableOpacity,
- ActivityIndicator,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
@@ -12,6 +11,8 @@ import { Package, ToggleLeft, ToggleRight, Trash2 } from "lucide-react-native";
import { usePackageManagement } from "~/hooks/usePackageManagement";
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";
/**
* Package management screen – list, enable, disable, uninstall packages.
@@ -58,31 +59,29 @@ export default function PackagesScreen() {
return (
-
+
{isLoading && !packages.length ? (
-
-
+
+
) : error ? (
-
-
- {error.message}
-
-
-
- Retry
-
-
+
+
) : !packages.length ? (
-
-
-
- No packages installed
-
+
+
) : (
@@ -97,6 +96,10 @@ export default function PackagesScreen() {
handleToggle(pkg.id, pkg.enabled)}
+ hitSlop={8}
+ accessibilityRole="switch"
+ accessibilityState={{ checked: pkg.enabled }}
+ accessibilityLabel={`${pkg.enabled ? "Disable" : "Enable"} ${pkg.label}`}
>
{pkg.enabled ? (
@@ -106,6 +109,9 @@ export default function PackagesScreen() {
handleUninstall(pkg.id, pkg.label)}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={`Uninstall ${pkg.label}`}
>
diff --git a/app/(app)/page/[id].tsx b/app/(app)/page/[id].tsx
index 1ddbd2a..30f4b7f 100644
--- a/app/(app)/page/[id].tsx
+++ b/app/(app)/page/[id].tsx
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from "react";
-import { View, Text } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams } from "expo-router";
import { useClient } from "@objectstack/client-react";
+import { AlertCircle } from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { EmptyState } from "~/components/ui/EmptyState";
import { PageRenderer } from "~/components/renderers/PageRenderer";
import {
validatePageSchema,
@@ -60,9 +61,12 @@ export default function SDUIPageScreen() {
{error && !isLoading ? (
-
- {error.message}
-
+
) : schema ? (
) : (
diff --git a/app/(auth)/server-config.tsx b/app/(auth)/server-config.tsx
index 5c67dc1..996aea9 100644
--- a/app/(auth)/server-config.tsx
+++ b/app/(auth)/server-config.tsx
@@ -5,11 +5,10 @@ import {
ScrollView,
KeyboardAvoidingView,
Platform,
- Alert,
- ActivityIndicator,
} from "react-native";
import { useRouter } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
+import { ServerCog } from "lucide-react-native";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { validateServerUrl } from "~/lib/server-url";
@@ -20,30 +19,28 @@ export default function ServerConfigScreen() {
const connect = useServerStore((s) => s.connect);
const [url, setUrl] = React.useState("");
const [loading, setLoading] = React.useState(false);
+ const [errorMsg, setErrorMsg] = React.useState(null);
const handleConnect = async () => {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) {
- Alert.alert("Error", "Please enter a server URL.");
+ setErrorMsg("Please enter a server URL.");
return;
}
// Basic URL format check
if (!/^https?:\/\/.+/i.test(trimmed)) {
- Alert.alert(
- "Invalid URL",
- "The URL must start with http:// or https://",
- );
+ setErrorMsg("The URL must start with http:// or https://");
return;
}
+ setErrorMsg(null);
setLoading(true);
try {
const isValid = await validateServerUrl(trimmed);
if (!isValid) {
- Alert.alert(
- "Connection Failed",
- "Could not reach the server. Please check the URL and try again.",
+ setErrorMsg(
+ "Could not reach the server. Please check the URL and try again."
);
return;
}
@@ -53,7 +50,7 @@ export default function ServerConfigScreen() {
await connect(trimmed);
router.replace("/(auth)/sign-in");
} catch {
- Alert.alert("Error", "Something went wrong. Please try again.");
+ setErrorMsg("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
@@ -69,10 +66,11 @@ export default function ServerConfigScreen() {
className="flex-1"
contentContainerClassName="px-6 pb-8 pt-16"
keyboardShouldPersistTaps="handled"
+ keyboardDismissMode="on-drag"
>
- 🔗
+
Connect to Server
@@ -93,29 +91,28 @@ export default function ServerConfigScreen() {
keyboardType="url"
textContentType="URL"
autoCorrect={false}
+ returnKeyType="go"
+ error={!!errorMsg}
value={url}
- onChangeText={setUrl}
+ onChangeText={(t) => {
+ setUrl(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
+ onSubmitEditing={handleConnect}
/>
-
- Example: https://app.objectstack.com
-
-
-
-
+
+
diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx
index e3cd618..ec5fea9 100644
--- a/app/(auth)/sign-in.tsx
+++ b/app/(auth)/sign-in.tsx
@@ -5,10 +5,11 @@ import {
ScrollView,
KeyboardAvoidingView,
Platform,
- Alert,
+ Pressable,
} from "react-native";
import { Link, useRouter } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
+import { Boxes, Eye, EyeOff } from "lucide-react-native";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { authClient } from "~/lib/auth-client";
@@ -17,32 +18,36 @@ export default function SignInScreen() {
const router = useRouter();
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
+ const [showPassword, setShowPassword] = React.useState(false);
const [loading, setLoading] = React.useState(false);
+ const [errorMsg, setErrorMsg] = React.useState(null);
const handleSignIn = async () => {
if (!email || !password) {
- Alert.alert("Error", "Please fill in all fields.");
+ setErrorMsg("Please enter your email and password.");
return;
}
+ setErrorMsg(null);
setLoading(true);
try {
const { error } = await authClient.signIn.email({
- email,
+ email: email.trim(),
password,
});
if (error) {
- Alert.alert("Sign In Failed", error.message ?? "An error occurred.");
+ setErrorMsg(error.message ?? "Sign in failed. Please try again.");
} else {
router.replace("/(tabs)");
}
} catch {
- Alert.alert("Error", "Something went wrong. Please try again.");
+ setErrorMsg("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
const handleSocialSignIn = async (provider: "google" | "apple") => {
+ setErrorMsg(null);
setLoading(true);
try {
await authClient.signIn.social({
@@ -50,7 +55,7 @@ export default function SignInScreen() {
callbackURL: "/(tabs)",
});
} catch {
- Alert.alert("Error", "Something went wrong. Please try again.");
+ setErrorMsg("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
@@ -64,14 +69,18 @@ export default function SignInScreen() {
>
-
-
+
+
+
+
+
Welcome back
-
+
Sign in to your account to continue.
@@ -81,18 +90,20 @@ export default function SignInScreen() {
Email
-
-
-
+ {
+ setEmail(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
+ />
@@ -101,18 +112,40 @@ export default function SignInScreen() {
{
+ setPassword(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
+ onSubmitEditing={handleSignIn}
+ rightSlot={
+ setShowPassword((v) => !v)}
+ hitSlop={10}
+ accessibilityRole="button"
+ accessibilityLabel={
+ showPassword ? "Hide password" : "Show password"
+ }
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+ }
/>
-
diff --git a/app/(auth)/sign-up.tsx b/app/(auth)/sign-up.tsx
index 7bee637..a36132c 100644
--- a/app/(auth)/sign-up.tsx
+++ b/app/(auth)/sign-up.tsx
@@ -5,10 +5,11 @@ import {
ScrollView,
KeyboardAvoidingView,
Platform,
- Alert,
+ Pressable,
} from "react-native";
import { Link, useRouter } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
+import { Boxes, Eye, EyeOff } from "lucide-react-native";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { authClient } from "~/lib/auth-client";
@@ -18,37 +19,41 @@ export default function SignUpScreen() {
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
+ const [showPassword, setShowPassword] = React.useState(false);
const [loading, setLoading] = React.useState(false);
+ const [errorMsg, setErrorMsg] = React.useState(null);
const handleSignUp = async () => {
if (!name || !email || !password) {
- Alert.alert("Error", "Please fill in all fields.");
+ setErrorMsg("Please fill in all fields.");
return;
}
if (password.length < 8) {
- Alert.alert("Error", "Password must be at least 8 characters.");
+ setErrorMsg("Password must be at least 8 characters.");
return;
}
+ setErrorMsg(null);
setLoading(true);
try {
const { error } = await authClient.signUp.email({
- name,
- email,
+ name: name.trim(),
+ email: email.trim(),
password,
});
if (error) {
- Alert.alert("Sign Up Failed", error.message ?? "An error occurred.");
+ setErrorMsg(error.message ?? "Sign up failed. Please try again.");
} else {
router.replace("/(tabs)");
}
} catch {
- Alert.alert("Error", "Something went wrong. Please try again.");
+ setErrorMsg("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
const handleSocialSignIn = async (provider: "google" | "apple") => {
+ setErrorMsg(null);
setLoading(true);
try {
await authClient.signIn.social({
@@ -56,7 +61,7 @@ export default function SignUpScreen() {
callbackURL: "/(tabs)",
});
} catch {
- Alert.alert("Error", "Something went wrong. Please try again.");
+ setErrorMsg("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
@@ -70,14 +75,18 @@ export default function SignUpScreen() {
>
-
-
+
+
+
+
+
Create account
-
+
Sign up to get started with ObjectStack.
@@ -90,9 +99,14 @@ export default function SignUpScreen() {
{
+ setName(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
/>
@@ -103,10 +117,15 @@ export default function SignUpScreen() {
{
+ setEmail(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
/>
@@ -116,18 +135,39 @@ export default function SignUpScreen() {
{
+ setPassword(t);
+ if (errorMsg) setErrorMsg(null);
+ }}
+ onSubmitEditing={handleSignUp}
+ rightSlot={
+ setShowPassword((v) => !v)}
+ hitSlop={10}
+ accessibilityRole="button"
+ accessibilityLabel={
+ showPassword ? "Hide password" : "Show password"
+ }
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+ }
/>
-
+ {errorMsg ? (
+ {errorMsg}
+ ) : null}
+
+
{loading ? "Creating account…" : "Create Account"}
diff --git a/app/(tabs)/apps.tsx b/app/(tabs)/apps.tsx
index 0837bc1..b3e02ae 100644
--- a/app/(tabs)/apps.tsx
+++ b/app/(tabs)/apps.tsx
@@ -1,9 +1,11 @@
-import { View, Text, ScrollView, Pressable, ActivityIndicator, RefreshControl } from "react-native";
+import { View, Text, ScrollView, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react";
-import { LayoutGrid, RefreshCw, ChevronRight } from "lucide-react-native";
-import { Card, CardContent } from "~/components/ui/Card";
+import { LayoutGrid, ChevronRight } from "lucide-react-native";
+import { PressableCard } from "~/components/ui/PressableCard";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useApps } from "~/hooks/useApps";
import { getIcon } from "~/lib/getIcon";
import { getUserErrorMessage } from "~/lib/error-handling";
@@ -23,65 +25,14 @@ export default function AppsScreen() {
setIsRefreshing(false);
}, [refetch]);
- if (isLoading && !isRefreshing) {
- return (
-
-
-
- Loading apps…
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
-
-
- Unable to Load Apps
-
-
- {getUserErrorMessage(error)}
-
-
-
- Retry
-
-
-
- );
- }
-
- if (apps.length === 0) {
- return (
-
-
-
-
-
-
- No Apps
-
- Your enterprise applications will appear here once installed.
-
-
-
-
- );
- }
+ const showSkeleton = isLoading && !isRefreshing;
return (
}
@@ -89,37 +40,64 @@ export default function AppsScreen() {
Apps
- {apps.length} app{apps.length !== 1 ? "s" : ""} installed
+ {showSkeleton
+ ? "Loading your apps…"
+ : `${apps.length} app${apps.length !== 1 ? "s" : ""} installed`}
-
- {apps.map((app) => {
- const Icon = getIcon(app.icon);
- return (
- handleAppPress(app.name)}>
-
-
-
-
-
-
-
- {app.label}
+ {showSkeleton ? (
+
+ ) : error ? (
+
+
+
+ ) : apps.length === 0 ? (
+
+
+
+ ) : (
+
+ {apps.map((app) => {
+ const Icon = getIcon(app.icon);
+ return (
+ handleAppPress(app.name)}
+ accessibilityRole="button"
+ accessibilityLabel={`Open ${app.label}`}
+ >
+
+
+
+
+
+ {app.label}
+
+ {app.description ? (
+
+ {app.description}
- {app.description ? (
-
- {app.description}
-
- ) : null}
-
-
-
-
-
- );
- })}
-
+ ) : null}
+
+
+
+ );
+ })}
+
+ )}
);
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 5a067e5..092f272 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,10 +1,12 @@
-import { View, Text, ScrollView, Pressable, ActivityIndicator, RefreshControl } from "react-native";
+import { View, Text, ScrollView, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-import { LayoutDashboard, ChevronRight, Inbox } from "lucide-react-native";
+import { LayoutDashboard, ChevronRight, Inbox, AlertCircle } from "lucide-react-native";
import { useClient } from "@objectstack/client-react";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
-import { Card, CardContent } from "~/components/ui/Card";
+import { PressableCard } from "~/components/ui/PressableCard";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useApps } from "~/hooks/useApps";
interface DashboardEntry {
@@ -105,59 +107,59 @@ export default function HomeScreen() {
{loading ? (
-
-
-
+
) : error ? (
-
- {error.message}
+
+ void fetchDashboards()}
+ />
) : dashboards.length === 0 ? (
-
-
-
-
-
- No Dashboards
-
-
- None of your installed apps publish a dashboard yet.
-
+
+
) : (
{dashboards.map((d) => (
-
router.push(`/(app)/${d.appId}/dashboard/${d.name}`)
}
+ accessibilityRole="button"
+ accessibilityLabel={`Open ${d.label} dashboard`}
>
-
-
-
-
-
-
-
- {d.label}
-
-
- {d.appLabel}
-
- {d.description ? (
-
- {d.description}
-
- ) : null}
-
-
-
-
-
+
+
+
+
+
+ {d.label}
+
+
+ {d.appLabel}
+
+ {d.description ? (
+
+ {d.description}
+
+ ) : null}
+
+
+
))}
)}
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index b5c2002..e873984 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -21,7 +21,7 @@ interface MenuItemProps {
function MenuItem({ icon, label, onPress, showChevron = true, destructive = false }: MenuItemProps) {
return (
{
+ const performSignOut = async () => {
try {
await authClient.signOut();
router.replace("/(auth)/sign-in");
@@ -60,6 +60,13 @@ export default function MoreScreen() {
}
};
+ 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() },
+ ]);
+ };
+
return (
diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx
index addb8b4..0692e11 100644
--- a/app/(tabs)/notifications.tsx
+++ b/app/(tabs)/notifications.tsx
@@ -1,9 +1,11 @@
import React from "react";
-import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { View, Text, ScrollView, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Bell, CheckCheck, Circle } from "lucide-react-native";
import { useRouter } from "expo-router";
import { cn } from "~/lib/utils";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useNotifications, type NotificationItem } from "~/hooks/useNotifications";
/* ------------------------------------------------------------------ */
@@ -20,10 +22,12 @@ function NotificationRow({
return (
onPress(notification)}
+ accessibilityRole="button"
+ accessibilityLabel={notification.title}
>
{notification.read ? (
@@ -105,29 +109,18 @@ export default function NotificationsScreen() {
{/* Loading */}
{isLoading && notifications.length === 0 && (
-
-
+
+
)}
{/* Empty state */}
{!isLoading && notifications.length === 0 && (
-
-
-
-
-
-
- No Notifications
-
-
- You're all caught up. New notifications will appear here.
-
-
-
+
)}
{/* Notification list */}
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index c661270..522a61c 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -9,7 +9,7 @@ export default function ProfileScreen() {
const { data: session } = authClient.useSession();
const router = useRouter();
- const handleSignOut = async () => {
+ const performSignOut = async () => {
try {
await authClient.signOut();
router.replace("/(auth)/sign-in");
@@ -18,6 +18,13 @@ export default function ProfileScreen() {
}
};
+ 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() },
+ ]);
+ };
+
return (
-
-
- {t("search.emptyTitle")}
-
-
- {objectCount > 0
+ 0
? t("search.lookingAcross", { count: objectCount })
- : t("search.emptyHint")}
-
-
+ : t("search.emptyHint")
+ }
+ />
)}
{/* No matches */}
{showResults && !isSearching && hasSearched && totalCount === 0 && (
-
-
-
-
-
- {t("search.noResultsTitle")}
-
-
- {t("search.noResultsBody", { query: query.trim() })}
-
-
+
)}
{/* Results */}
diff --git a/app/account.tsx b/app/account.tsx
index 10efd23..3174001 100644
--- a/app/account.tsx
+++ b/app/account.tsx
@@ -166,8 +166,8 @@ export default function AccountScreen() {
placeholder="Your name"
autoCapitalize="words"
/>
-
- Save profile
+
+ Save profile
@@ -184,11 +184,11 @@ export default function AccountScreen() {
autoCapitalize="none"
keyboardType="email-address"
/>
-
- Change email
+
+ Change email
- Resend verification
+ Resend verification
@@ -217,8 +217,8 @@ export default function AccountScreen() {
secureTextEntry
autoCapitalize="none"
/>
-
- Change password
+
+ Change password
@@ -236,8 +236,8 @@ export default function AccountScreen() {
secureTextEntry
autoCapitalize="none"
/>
-
- Disable 2FA
+
+ Disable 2FA
>
) : tfUri ? (
@@ -271,8 +271,8 @@ export default function AccountScreen() {
keyboardType="number-pad"
maxLength={6}
/>
-
- Confirm & enable
+
+ Confirm & enable
>
) : (
@@ -288,8 +288,8 @@ export default function AccountScreen() {
secureTextEntry
autoCapitalize="none"
/>
-
- Enable 2FA
+
+ Enable 2FA
>
)}
diff --git a/components/common/FloatingActionButton.tsx b/components/common/FloatingActionButton.tsx
index 300f7ed..56ad79e 100644
--- a/components/common/FloatingActionButton.tsx
+++ b/components/common/FloatingActionButton.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { TouchableOpacity, View, Text } from "react-native";
+import * as Haptics from "expo-haptics";
export interface FABAction {
id: string;
@@ -22,6 +23,7 @@ export function FloatingActionButton({
const [expanded, setExpanded] = React.useState(false);
const handlePress = () => {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (actions && actions.length > 0) {
setExpanded((prev) => !prev);
} else if (onPress) {
@@ -37,12 +39,13 @@ export function FloatingActionButton({
key={action.id}
testID={`${testID}-action-${action.id}`}
onPress={() => {
+ void Haptics.selectionAsync();
action.onPress();
setExpanded(false);
}}
accessibilityLabel={action.label}
accessibilityRole="button"
- className="mb-2 flex-row items-center rounded-full bg-card px-4 py-2 shadow"
+ className="mb-2 flex-row items-center rounded-full bg-card px-4 py-2 shadow active:opacity-80"
>
{action.label}
diff --git a/components/common/LanguageSelector.tsx b/components/common/LanguageSelector.tsx
index ca81b38..53bdf12 100644
--- a/components/common/LanguageSelector.tsx
+++ b/components/common/LanguageSelector.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { View, Text, Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react-native";
+import * as Haptics from "expo-haptics";
import { SUPPORTED_LANGUAGES } from "~/lib/i18n";
import { useUIStore } from "~/stores/ui-store";
import { cn } from "~/lib/utils";
@@ -25,11 +26,19 @@ export function LanguageSelector({ className }: { className?: string }) {
return (
setLanguage(lang.code)}
+ onPress={() => {
+ if (!isActive) {
+ void Haptics.selectionAsync();
+ setLanguage(lang.code);
+ }
+ }}
>
{
+ if (timerRef.current) clearTimeout(timerRef.current);
+ setLocalValue("");
+ onChangeText("");
+ }, [onChangeText]);
+
return (
+ {localValue.length > 0 ? (
+
+
+
+ ) : null}
);
}
diff --git a/components/renderers/CalendarViewRenderer.tsx b/components/renderers/CalendarViewRenderer.tsx
index 303f9bc..26e52de 100644
--- a/components/renderers/CalendarViewRenderer.tsx
+++ b/components/renderers/CalendarViewRenderer.tsx
@@ -4,11 +4,31 @@ import {
Text,
ScrollView,
Pressable,
- ActivityIndicator,
} from "react-native";
import { ChevronLeft, ChevronRight } from "lucide-react-native";
+import { Skeleton } from "~/components/ui/Skeleton";
import { cn } from "~/lib/utils";
+/** Skeleton placeholder shown while calendar events load. */
+function CalendarSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 35 }).map((_, i) => (
+
+
+
+ ))}
+
+
+ );
+}
+
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
@@ -155,11 +175,7 @@ export function CalendarViewRenderer({
const selectedEvents = selectedDate ? eventsByDate[selectedDate] ?? [] : [];
if (isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/components/renderers/ChartViewRenderer.tsx b/components/renderers/ChartViewRenderer.tsx
index 0e5cffd..e0bddd5 100644
--- a/components/renderers/ChartViewRenderer.tsx
+++ b/components/renderers/ChartViewRenderer.tsx
@@ -1,7 +1,9 @@
import React, { useMemo } from "react";
-import { View, Text, ScrollView, ActivityIndicator } from "react-native";
-import { BarChart3, TrendingUp, PieChart, Activity } from "lucide-react-native";
+import { View, Text, ScrollView } from "react-native";
+import { BarChart3, TrendingUp, PieChart, Activity, AlertCircle } from "lucide-react-native";
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
+import { Skeleton } from "~/components/ui/Skeleton";
+import { EmptyState } from "~/components/ui/EmptyState";
import type { AnalyticsDataPoint } from "~/hooks/useAnalyticsQuery";
/* ------------------------------------------------------------------ */
@@ -242,17 +244,40 @@ export function ChartViewRenderer({
}: ChartViewRendererProps) {
if (isLoading) {
return (
-
-
-
+
+
+
+
+
+
+
+
+ {[0.5, 0.8, 0.4, 1, 0.65, 0.3].map((h, i) => (
+
+
+
+ ))}
+
+
+
+
);
}
if (error) {
return (
-
- {error.message}
-
+
);
}
diff --git a/components/renderers/DashboardViewRenderer.tsx b/components/renderers/DashboardViewRenderer.tsx
index badb3a8..955dd66 100644
--- a/components/renderers/DashboardViewRenderer.tsx
+++ b/components/renderers/DashboardViewRenderer.tsx
@@ -9,10 +9,30 @@ import {
Activity,
} from "lucide-react-native";
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
+import { Skeleton } from "~/components/ui/Skeleton";
import { WidgetChart } from "./charts/WidgetChart";
import { formatByPattern, formatCurrency, formatNumber } from "~/lib/formatting";
import type { DashboardMeta, DashboardWidgetMeta } from "./types";
+/** Skeleton grid shown while dashboard metadata + widget data load. */
+function DashboardSkeleton() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
/** Value fields whose name implies a monetary amount (for metric formatting). */
const CURRENCY_FIELD_RE =
/amount|revenue|price|cost|total|salary|value|deal|mrr|arr|budget|fee|balance/i;
@@ -299,18 +319,16 @@ export function DashboardViewRenderer({
const numColumns = screenWidth >= GRID_BREAKPOINT ? 2 : 1;
if (isLoading) {
- return (
-
-
-
- );
+ return ;
}
if (!dashboard || !dashboard.widgets || dashboard.widgets.length === 0) {
return (
-
- No Dashboard
+
+
+
+ No Dashboard
No dashboard widgets have been configured.
diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx
index ce8aad4..7bf1fc1 100644
--- a/components/renderers/DetailViewRenderer.tsx
+++ b/components/renderers/DetailViewRenderer.tsx
@@ -1,10 +1,36 @@
import React, { useMemo } from "react";
-import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
-import { Edit, Trash2, ChevronLeft, ChevronRight } from "lucide-react-native";
+import { View, Text, ScrollView, Pressable } from "react-native";
+import { Edit, Trash2, ChevronLeft, ChevronRight, AlertCircle } from "lucide-react-native";
import { cn } from "~/lib/utils";
+import { EmptyState } from "~/components/common/EmptyState";
+import { Button } from "~/components/ui/Button";
+import { Skeleton } from "~/components/ui/Skeleton";
import { FieldRenderer } from "./fields/FieldRenderer";
import type { FieldDefinition, FormViewMeta, FormSection, ActionMeta } from "./types";
+/** Skeleton placeholder that mirrors the detail's stacked section/field layout. */
+function DetailSkeleton() {
+ return (
+
+ {[0, 1].map((s) => (
+
+
+ {[0, 1, 2].map((r) => (
+
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
@@ -92,8 +118,10 @@ function DetailActionBar({
{onEdit && (
Edit
@@ -101,8 +129,10 @@ function DetailActionBar({
)}
{onDelete && (
Delete
@@ -111,8 +141,10 @@ function DetailActionBar({
{actions?.map((action) => (
onAction?.(action)}
+ accessibilityRole="button"
+ accessibilityLabel={action.label}
>
{action.label}
@@ -289,27 +321,28 @@ export function DetailViewRenderer({
/* ---- Loading ---- */
if (isLoading) {
- return (
-
-
-
- );
+ return ;
}
/* ---- Error ---- */
if (error) {
return (
-
- {error.message}
- {onRetry && (
-
- Retry
-
- )}
-
+
+
+
+ }
+ title="Couldn't Load Record"
+ description={error.message}
+ action={
+ onRetry ? (
+
+ Retry
+
+ ) : undefined
+ }
+ />
);
}
diff --git a/components/renderers/FormViewRenderer.tsx b/components/renderers/FormViewRenderer.tsx
index cb46601..b9a4168 100644
--- a/components/renderers/FormViewRenderer.tsx
+++ b/components/renderers/FormViewRenderer.tsx
@@ -4,7 +4,6 @@ import {
Text,
ScrollView,
Pressable,
- ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
@@ -262,23 +261,18 @@ export function FormViewRenderer({
- Cancel
+ Cancel
)}
- {isSubmitting ? (
-
- ) : (
-
- {submitLabel}
-
- )}
+ {isSubmitting ? "Saving…" : submitLabel}
)}
diff --git a/components/renderers/GalleryViewRenderer.tsx b/components/renderers/GalleryViewRenderer.tsx
index d9cda91..f45f2eb 100644
--- a/components/renderers/GalleryViewRenderer.tsx
+++ b/components/renderers/GalleryViewRenderer.tsx
@@ -4,6 +4,7 @@ import { FlashList } from "@shopify/flash-list";
import { Image } from "expo-image";
import { cn } from "~/lib/utils";
import { EmptyState } from "~/components/common/EmptyState";
+import { Skeleton } from "~/components/ui/Skeleton";
import { Image as ImageIcon } from "lucide-react-native";
/* ------------------------------------------------------------------ */
@@ -137,6 +138,24 @@ export function GalleryViewRenderer({
[imageField, titleField, subtitleField, aspectRatio, onCardPress],
);
+ if (isLoading && records.length === 0) {
+ return (
+
+ {Array.from({ length: numColumns * 3 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
if (!isLoading && records.length === 0) {
return (
+
+
+
+
+ {Array.from({ length: 7 }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+ );
+ }
+
if (!isLoading && tasks.length === 0) {
return (
(
setSelectedIndex(index)}
+ accessibilityRole="imagebutton"
+ accessibilityLabel={item.label ?? `Image ${index + 1}`}
+ className="active:opacity-70"
style={{ width: tileSize, height: tileSize, marginRight: gap, marginBottom: gap }}
>
-
+
+ {Array.from({ length: columns * 4 }).map((_, i) => (
+
+
+
+ ))}
);
}
if (images.length === 0) {
return (
-
- No images
-
+
);
}
diff --git a/components/renderers/KanbanViewRenderer.tsx b/components/renderers/KanbanViewRenderer.tsx
index de3d8b5..1325455 100644
--- a/components/renderers/KanbanViewRenderer.tsx
+++ b/components/renderers/KanbanViewRenderer.tsx
@@ -5,11 +5,32 @@ import {
ScrollView,
FlatList,
Pressable,
- ActivityIndicator,
} from "react-native";
import { GripVertical, Plus } from "lucide-react-native";
+import { Skeleton } from "~/components/ui/Skeleton";
import type { FieldDefinition } from "./types";
+/** Horizontal skeleton columns shown while the board loads. */
+function KanbanSkeleton() {
+ return (
+
+ {[0, 1, 2].map((c) => (
+
+
+
+
+ {[0, 1, 2].map((r) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ );
+}
+
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
@@ -226,11 +247,7 @@ export function KanbanViewRenderer({
}, [records, groupField, columns]);
if (isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/components/renderers/ListViewRenderer.tsx b/components/renderers/ListViewRenderer.tsx
index 3bed891..f6419b3 100644
--- a/components/renderers/ListViewRenderer.tsx
+++ b/components/renderers/ListViewRenderer.tsx
@@ -7,9 +7,11 @@ import {
RefreshControl,
} from "react-native";
import { FlashList } from "@shopify/flash-list";
-import { ChevronDown, ChevronUp, Check, Search as SearchIcon } from "lucide-react-native";
+import { ChevronDown, ChevronUp, Check, Search as SearchIcon, AlertCircle } from "lucide-react-native";
import { cn } from "~/lib/utils";
import { EmptyState } from "~/components/common/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { Button } from "~/components/ui/Button";
import { SearchBar } from "~/components/common/SearchBar";
import { BatchActionBar } from "~/components/batch/BatchActionBar";
import { formatDisplayValue } from "./fields/FieldRenderer";
@@ -561,17 +563,22 @@ export function ListViewRenderer({
/* ---- Error state ---- */
if (error && !isLoading) {
return (
-
- {error.message}
- {onRefresh && (
-
- Retry
-
- )}
-
+
+
+
+ }
+ title="Couldn't Load Records"
+ description={error.message}
+ action={
+ onRefresh ? (
+ void onRefresh()}>
+ Retry
+
+ ) : undefined
+ }
+ />
);
}
@@ -664,12 +671,14 @@ export function ListViewRenderer({
}
ListEmptyComponent={
isLoading ? (
-
-
-
+
) : (
}
+ icon={
+
+
+
+ }
title="No Records"
description="No records found for this view."
/>
diff --git a/components/renderers/MapViewRenderer.tsx b/components/renderers/MapViewRenderer.tsx
index 5d63248..7649560 100644
--- a/components/renderers/MapViewRenderer.tsx
+++ b/components/renderers/MapViewRenderer.tsx
@@ -1,7 +1,8 @@
import React from "react";
-import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { View, Text, ScrollView, Pressable } from "react-native";
import { MapPin, Navigation } from "lucide-react-native";
import { Card, CardContent } from "~/components/ui/Card";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
/* ------------------------------------------------------------------ */
/* Types */
@@ -96,8 +97,8 @@ export function MapViewRenderer({
}: MapViewRendererProps) {
if (isLoading) {
return (
-
-
+
+
);
}
diff --git a/components/renderers/PageRenderer.tsx b/components/renderers/PageRenderer.tsx
index 6075271..fc768f7 100644
--- a/components/renderers/PageRenderer.tsx
+++ b/components/renderers/PageRenderer.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { View, Text, ScrollView, ActivityIndicator } from "react-native";
+import { View, Text, ScrollView } from "react-native";
+import { AlertCircle } from "lucide-react-native";
import {
resolvePageSchema,
type PageSchema,
@@ -7,6 +8,8 @@ import {
type ResolvedComponent,
} from "~/lib/page-renderer";
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
+import { Skeleton } from "~/components/ui/Skeleton";
+import { EmptyState } from "~/components/ui/EmptyState";
/* ------------------------------------------------------------------ */
/* Types */
@@ -242,17 +245,34 @@ export function PageRenderer({
}: PageRendererProps) {
if (isLoading) {
return (
-
-
-
+
+
+
+
+
+ {[0, 1].map((i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
);
}
if (error) {
return (
-
- {error.message}
-
+
);
}
diff --git a/components/renderers/ReportRenderer.tsx b/components/renderers/ReportRenderer.tsx
index abd5a8b..e02a353 100644
--- a/components/renderers/ReportRenderer.tsx
+++ b/components/renderers/ReportRenderer.tsx
@@ -1,7 +1,9 @@
import React, { useMemo } from "react";
-import { View, Text, ScrollView, ActivityIndicator } from "react-native";
-import { FileText } from "lucide-react-native";
+import { View, Text, ScrollView } from "react-native";
+import { FileText, AlertCircle } from "lucide-react-native";
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
+import { Skeleton } from "~/components/ui/Skeleton";
+import { EmptyState } from "~/components/ui/EmptyState";
/* ------------------------------------------------------------------ */
/* Types */
@@ -307,25 +309,46 @@ export function ReportRenderer({
}: ReportRendererProps) {
if (isLoading) {
return (
-
-
-
+
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+
+
+
);
}
if (error) {
return (
-
- {error.message}
-
+
);
}
if (!data.length) {
return (
-
- No report data available
-
+
);
}
diff --git a/components/renderers/SwipeableRow.tsx b/components/renderers/SwipeableRow.tsx
index 79e6f9e..af787e4 100644
--- a/components/renderers/SwipeableRow.tsx
+++ b/components/renderers/SwipeableRow.tsx
@@ -75,7 +75,9 @@ export function SwipeableRow({
close();
onEdit();
}}
- className="items-center justify-center bg-blue-600"
+ accessibilityRole="button"
+ accessibilityLabel="Edit"
+ className="items-center justify-center bg-blue-600 active:opacity-80"
style={{ width: ACTION_WIDTH }}
>
@@ -89,7 +91,9 @@ export function SwipeableRow({
close();
onDelete();
}}
- className="items-center justify-center bg-red-600"
+ accessibilityRole="button"
+ accessibilityLabel="Delete"
+ className="items-center justify-center bg-red-600 active:opacity-80"
style={{ width: ACTION_WIDTH }}
>
diff --git a/components/renderers/TimelineViewRenderer.tsx b/components/renderers/TimelineViewRenderer.tsx
index bb28fd3..725cc9e 100644
--- a/components/renderers/TimelineViewRenderer.tsx
+++ b/components/renderers/TimelineViewRenderer.tsx
@@ -1,6 +1,7 @@
import React from "react";
-import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { View, Text, ScrollView, Pressable } from "react-native";
import { Circle, CheckCircle2, AlertCircle, Clock, User, FileText } from "lucide-react-native";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
/* ------------------------------------------------------------------ */
/* Types */
@@ -99,8 +100,8 @@ export function TimelineViewRenderer({
}: TimelineViewRendererProps) {
if (isLoading) {
return (
-
-
+
+
);
}
diff --git a/components/ui/BottomSheet.tsx b/components/ui/BottomSheet.tsx
index bb84822..13fbc95 100644
--- a/components/ui/BottomSheet.tsx
+++ b/components/ui/BottomSheet.tsx
@@ -26,6 +26,8 @@ export function BottomSheet({
>
onOpenChange(false)}
/>
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
index 1b3854a..2084c38 100644
--- a/components/ui/Button.tsx
+++ b/components/ui/Button.tsx
@@ -1,5 +1,6 @@
import React from "react";
import {
+ ActivityIndicator,
Pressable,
Text,
type PressableProps,
@@ -34,6 +35,13 @@ const buttonTextSizes = {
lg: "text-lg",
} as const;
+const spinnerColor: Record = {
+ default: "#f8fafc",
+ destructive: "#f8fafc",
+ outline: "#64748b",
+ ghost: "#64748b",
+};
+
export interface ButtonProps extends PressableProps {
variant?: keyof typeof buttonVariants;
size?: keyof typeof buttonSizes;
@@ -41,6 +49,8 @@ export interface ButtonProps extends PressableProps {
textClassName?: string;
children: React.ReactNode;
style?: ViewStyle;
+ /** Shows a spinner, keeps the button sized, and blocks presses while true. */
+ loading?: boolean;
}
export function Button({
@@ -50,9 +60,12 @@ export function Button({
textClassName,
children,
disabled,
+ loading = false,
onPress,
...props
}: ButtonProps) {
+ const isDisabled = disabled || loading;
+
const handlePress = React.useCallback(
(e: Parameters>[0]) => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@@ -63,17 +76,22 @@ export function Button({
return (
+ {loading ? (
+
+ ) : null}
{typeof children === "string" ? (
{
+ void Haptics.selectionAsync();
+ onCheckedChange(!checked);
+ };
+
return (
onCheckedChange(!checked)}
+ accessibilityState={{ checked, disabled: !!disabled }}
+ onPress={handlePress}
disabled={disabled}
+ hitSlop={8}
className={cn(
"h-5 w-5 items-center justify-center rounded border",
checked
? "border-primary bg-primary"
: "border-input bg-background",
- disabled && "opacity-50",
+ disabled ? "opacity-50" : "active:opacity-70",
className
)}
>
- {checked && }
+ {checked && }
);
}
diff --git a/components/ui/DatePicker.tsx b/components/ui/DatePicker.tsx
index c3568f7..e949ba8 100644
--- a/components/ui/DatePicker.tsx
+++ b/components/ui/DatePicker.tsx
@@ -8,6 +8,7 @@ import {
ChevronUp,
ChevronDown,
} from "lucide-react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
import { formatDate, formatDateTime } from "~/lib/formatting";
@@ -182,8 +183,10 @@ export function DatePicker({
<>
) : (
{
+ void Haptics.selectionAsync();
const picked = new Date(viewYear, viewMonth, d);
setDraftDay(picked);
if (mode === "date") {
diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..8eb2e82
--- /dev/null
+++ b/components/ui/EmptyState.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { View, Text } from "react-native";
+import type { LucideIcon } from "lucide-react-native";
+import { Button } from "~/components/ui/Button";
+
+export interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ /** Optional call-to-action rendered below the copy (e.g. a Retry button). */
+ actionLabel?: string;
+ onAction?: () => void;
+ /** Spinner on the action button while an async retry runs. */
+ actionLoading?: boolean;
+ /** Tints the icon badge to signal an error rather than an empty result. */
+ variant?: "default" | "error";
+ className?: string;
+}
+
+/**
+ * A centred icon-badge + title + body used for empty, error, and idle states
+ * across the app. Keeps every "nothing here" screen visually consistent.
+ */
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ actionLabel,
+ onAction,
+ actionLoading,
+ variant = "default",
+ className,
+}: EmptyStateProps) {
+ const isError = variant === "error";
+ return (
+
+
+
+
+ {title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null}
+
+ );
+}
diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx
index 8933fd2..500396a 100644
--- a/components/ui/Input.tsx
+++ b/components/ui/Input.tsx
@@ -5,24 +5,35 @@ import { cn } from "~/lib/utils";
export interface InputProps extends TextInputProps {
className?: string;
containerClassName?: string;
+ /** Render an element on the trailing edge (e.g. a password visibility toggle). */
+ rightSlot?: React.ReactNode;
+ /** Render an element on the leading edge (e.g. a search icon). */
+ leftSlot?: React.ReactNode;
+ /** Switches the border to the destructive colour to signal a validation error. */
+ error?: boolean;
}
export const Input = React.forwardRef(
- ({ className, containerClassName, ...props }, ref) => {
+ (
+ { className, containerClassName, rightSlot, leftSlot, error, ...props },
+ ref
+ ) => {
const [isFocused, setIsFocused] = React.useState(false);
return (
+ {leftSlot ? {leftSlot} : null}
(
}}
{...props}
/>
+ {rightSlot ? {rightSlot} : null}
);
}
diff --git a/components/ui/ListSkeleton.tsx b/components/ui/ListSkeleton.tsx
new file mode 100644
index 0000000..a8952eb
--- /dev/null
+++ b/components/ui/ListSkeleton.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { View } from "react-native";
+import { Skeleton } from "~/components/ui/Skeleton";
+
+export interface ListSkeletonProps {
+ /** How many placeholder rows to render. */
+ count?: number;
+}
+
+/**
+ * Placeholder rows that mirror the icon + title + subtitle card layout used by
+ * the home, apps, and search lists. Showing the shape of incoming content reads
+ * faster and calmer than a centred spinner.
+ */
+export function ListSkeleton({ count = 5 }: ListSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/ui/MultiSelect.tsx b/components/ui/MultiSelect.tsx
index cf76984..054c5dc 100644
--- a/components/ui/MultiSelect.tsx
+++ b/components/ui/MultiSelect.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { Modal, Pressable, ScrollView, Text, View } from "react-native";
import { ChevronDown, Check, X } from "lucide-react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
import type { SelectOption } from "~/components/renderers/types";
@@ -35,6 +36,7 @@ export function MultiSelect({
const selectedOptions = options.filter((o) => selectedSet.has(String(o.value)));
const toggle = (val: string) => {
+ void Haptics.selectionAsync();
const next = new Set(selectedSet);
if (next.has(val)) next.delete(val);
else next.add(val);
@@ -45,8 +47,10 @@ export function MultiSelect({
<>
setOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={selectedOptions.length ? selectedOptions.map((o) => o.label).join(", ") : placeholder}
className={cn(
- "min-h-12 flex-row items-center justify-between rounded-xl border bg-background px-3 py-2",
+ "min-h-12 flex-row items-center justify-between rounded-xl border bg-background px-3 py-2 active:opacity-70",
error ? "border-destructive" : "border-input",
className,
)}
@@ -86,6 +90,8 @@ export function MultiSelect({
return (
toggle(String(option.value))}
className="flex-row items-center justify-between rounded-lg px-3 py-3 active:bg-accent"
>
@@ -96,7 +102,7 @@ export function MultiSelect({
checked ? "border-primary bg-primary" : "border-input bg-background",
)}
>
- {checked && }
+ {checked && }
);
diff --git a/components/ui/PressableCard.tsx b/components/ui/PressableCard.tsx
new file mode 100644
index 0000000..a238779
--- /dev/null
+++ b/components/ui/PressableCard.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { Pressable, type PressableProps } from "react-native";
+import * as Haptics from "expo-haptics";
+import { cn } from "~/lib/utils";
+
+export interface PressableCardProps extends PressableProps {
+ className?: string;
+ children: React.ReactNode;
+ /** Fire a light haptic tap on press (native only; no-op on web). */
+ haptic?: boolean;
+}
+
+/**
+ * A card-styled Pressable with the tactile feedback users expect from a
+ * top-tier app: a subtle scale-down + shadow/opacity change on press, plus an
+ * optional light haptic. Use for any tappable card row (list items, dashboard
+ * tiles) so press affordance is consistent everywhere.
+ */
+export function PressableCard({
+ className,
+ children,
+ haptic = true,
+ onPress,
+ ...props
+}: PressableCardProps) {
+ const handlePress = React.useCallback(
+ (e: Parameters>[0]) => {
+ if (haptic) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ onPress?.(e);
+ },
+ [haptic, onPress]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/ui/Select.tsx b/components/ui/Select.tsx
index 9e46844..4dc423d 100644
--- a/components/ui/Select.tsx
+++ b/components/ui/Select.tsx
@@ -7,6 +7,7 @@ import {
View,
} from "react-native";
import { ChevronDown, Check } from "lucide-react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
export interface SelectOption {
@@ -36,8 +37,10 @@ export function Select({
<>
setOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={selectedLabel ?? placeholder}
className={cn(
- "h-12 flex-row items-center justify-between rounded-xl border border-input bg-background px-4",
+ "h-12 flex-row items-center justify-between rounded-xl border border-input bg-background px-4 active:opacity-70",
className
)}
>
@@ -67,7 +70,10 @@ export function Select({
{options.map((option) => (
{
+ void Haptics.selectionAsync();
onValueChange(option.value);
setOpen(false);
}}
diff --git a/components/ui/Switch.tsx b/components/ui/Switch.tsx
index c3f49b0..176b34d 100644
--- a/components/ui/Switch.tsx
+++ b/components/ui/Switch.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { Pressable, View, type ViewStyle } from "react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
export interface SwitchProps {
@@ -19,16 +20,22 @@ export function Switch({
transform: [{ translateX: checked ? 20 : 2 }],
};
+ const handlePress = () => {
+ void Haptics.selectionAsync();
+ onCheckedChange(!checked);
+ };
+
return (
onCheckedChange(!checked)}
+ accessibilityState={{ checked, disabled: !!disabled }}
+ onPress={handlePress}
disabled={disabled}
+ hitSlop={6}
className={cn(
"h-7 w-12 rounded-full justify-center",
checked ? "bg-primary" : "bg-input",
- disabled && "opacity-50",
+ disabled ? "opacity-50" : "active:opacity-80",
className
)}
>
diff --git a/components/ui/Tabs.tsx b/components/ui/Tabs.tsx
index c2cb1f6..93c1265 100644
--- a/components/ui/Tabs.tsx
+++ b/components/ui/Tabs.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
export interface TabsProps {
@@ -47,9 +48,16 @@ export function Tabs({
return (
onValueChange(item.props.value)}
+ accessibilityRole="tab"
+ accessibilityState={{ selected: isActive }}
+ onPress={() => {
+ if (!isActive) {
+ void Haptics.selectionAsync();
+ onValueChange(item.props.value);
+ }
+ }}
className={cn(
- "px-3 pb-2",
+ "px-3 pb-2 active:opacity-70",
isActive && "border-b-2 border-primary"
)}
>
diff --git a/components/ui/Toast.tsx b/components/ui/Toast.tsx
index 82c67eb..3889755 100644
--- a/components/ui/Toast.tsx
+++ b/components/ui/Toast.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { Pressable, Text, View } from "react-native";
import { X } from "lucide-react-native";
+import * as Haptics from "expo-haptics";
import { cn } from "~/lib/utils";
type ToastVariant = "default" | "error" | "success";
@@ -43,6 +44,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
const addToast = React.useCallback(
(message: string, variant: ToastVariant) => {
const id = ++nextId;
+ if (variant === "error") {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ } else if (variant === "success") {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
setToasts((prev) => [...prev, { id, message, variant }]);
setTimeout(() => dismiss(id), 3000);
},
@@ -65,6 +71,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
{toasts.map((t) => (
{t.message}
- dismiss(t.id)} hitSlop={8}>
+ dismiss(t.id)}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel="Dismiss"
+ className="active:opacity-70"
+ >
diff --git a/lib/auth-client.ts b/lib/auth-client.ts
index d39edce..efd5487 100644
--- a/lib/auth-client.ts
+++ b/lib/auth-client.ts
@@ -39,10 +39,52 @@ let currentBaseURL =
*/
const AUTH_BASE_PATH = "/api/v1/auth";
+/**
+ * The scheme+host origin of the server (e.g. `https://cloud.objectos.app`),
+ * derived from the configured base URL.
+ */
+function toServerOrigin(serverUrl: string): string {
+ const trimmed = serverUrl.replace(/\/+$/, "");
+ try {
+ const u = new URL(trimmed);
+ return `${u.protocol}//${u.host}`;
+ } catch {
+ return trimmed;
+ }
+}
+
+/**
+ * better-auth enforces a CSRF origin check on state-changing requests that
+ * carry a session cookie: the request's `Origin` (or `Referer`) must match a
+ * trusted origin, and the server always trusts its own base URL. React Native
+ * — unlike a browser — never sets `Origin` automatically, and the ObjectStack
+ * server does not run the `@better-auth/expo` server plugin, so it can't derive
+ * the origin from the `expo-origin` header the client sends. The result is a
+ * `MISSING_OR_NULL_ORIGIN` ("Missing or null Origin") rejection on the first
+ * cookied request after sign-in.
+ *
+ * We stamp the server's own origin on every auth request (without clobbering an
+ * `Origin` already present) so the check passes.
+ */
+function makeAuthFetch(
+ serverOrigin: string,
+): (input: RequestInfo | URL, init?: RequestInit) => Promise {
+ return (input, init = {}) => {
+ const headers = new Headers(init.headers);
+ if (!headers.has("Origin")) {
+ headers.set("Origin", serverOrigin);
+ }
+ return fetch(input, { ...init, headers });
+ };
+}
+
export let authClient = createAuthClient({
baseURL: currentBaseURL,
basePath: AUTH_BASE_PATH,
plugins: makePlugins(),
+ fetchOptions: {
+ customFetchImpl: makeAuthFetch(toServerOrigin(currentBaseURL)),
+ },
});
/**
@@ -55,6 +97,9 @@ export function reinitializeAuthClient(baseURL: string) {
baseURL: currentBaseURL,
basePath: AUTH_BASE_PATH,
plugins: makePlugins(),
+ fetchOptions: {
+ customFetchImpl: makeAuthFetch(toServerOrigin(currentBaseURL)),
+ },
});
}
diff --git a/lib/objectstack.ts b/lib/objectstack.ts
index 0ba0981..f75035b 100644
--- a/lib/objectstack.ts
+++ b/lib/objectstack.ts
@@ -30,6 +30,20 @@ export function setObjectStackApiUrl(url: string) {
API_URL = url;
}
+/**
+ * The scheme+host origin of the server (e.g. `https://cloud.objectos.app`),
+ * derived from the configured base URL.
+ */
+function toServerOrigin(serverUrl: string): string {
+ const trimmed = serverUrl.replace(/\/+$/, "");
+ try {
+ const u = new URL(trimmed);
+ return `${u.protocol}//${u.host}`;
+ } catch {
+ return trimmed;
+ }
+}
+
/**
* Fetch wrapper that carries the better-auth session to the ObjectStack data
* API. Auth and data share the same better-auth session, but the credential
@@ -47,10 +61,21 @@ function authAwareFetch(input: RequestInfo | URL, init?: RequestInit): Promise = {
+ ...(init?.headers as Record),
+ };
const cookie = getNativeAuthCookie();
if (cookie) {
- next.headers = { ...(init?.headers as Record), cookie };
+ headers.cookie = cookie;
+ }
+ if (!("Origin" in headers) && !("origin" in headers)) {
+ headers.Origin = toServerOrigin(API_URL);
}
+ next.headers = headers;
next.credentials = "omit";
}
return globalThis.fetch(input as RequestInfo, next);
From 5f11caa132f4fab2c0143efdbd87a6db949a98ad Mon Sep 17 00:00:00 2001
From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Date: Sun, 31 May 2026 14:57:56 +0800
Subject: [PATCH 2/2] feat(actions): wire object actions + fix i18n init on
record detail
- Initialize i18next at app root (import ~/lib/i18n) so all t() resolve;
previously only LanguageSelector imported it, leaving every screen
showing raw keys ('missing i18n resources').
- Mount the previously-unmounted ToastProvider so useToast() works.
- Add apiFetch()/getApiUrl() to lib/objectstack for authed non-SDK routes
(carries better-auth cookie + CSRF Origin header).
- Add lib/record-actions engine (confirm -> params -> dispatch by type:
url/flow/api/script/modal -> toast/result dialog/refresh), modeled on
the objectui ActionRunner.
- Add hooks/useRecordActions wiring the engine to native UI (Alert confirm,
param BottomSheet, result Modal, toasts, per-button spinner).
- Enhance DetailViewRenderer with variant-styled header action buttons and
a '...' overflow menu for record_more actions.
- Wire meta.actions into the record detail screen, filtered by location and
visibility.
- Localize new action strings (actions.*) into en/zh/ar.
---
app/(app)/[appName]/[objectName]/[id].tsx | 34 ++-
app/_layout.tsx | 20 +-
components/renderers/DetailViewRenderer.tsx | 161 +++++++++-
components/renderers/types.ts | 2 +
hooks/useRecordActions.tsx | 311 ++++++++++++++++++++
lib/objectstack.ts | 25 ++
lib/record-actions.ts | 273 +++++++++++++++++
locales/ar.json | 8 +
locales/en.json | 8 +
locales/zh.json | 8 +
10 files changed, 827 insertions(+), 23 deletions(-)
create mode 100644 hooks/useRecordActions.tsx
create mode 100644 lib/record-actions.ts
diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx
index 5f6b429..53bdfd6 100644
--- a/app/(app)/[appName]/[objectName]/[id].tsx
+++ b/app/(app)/[appName]/[objectName]/[id].tsx
@@ -5,9 +5,11 @@ import { useClient, useQuery, useView } from "@objectstack/client-react";
import { useTranslation } from "react-i18next";
import { useEffect, useState, useCallback, useMemo } from "react";
import { DetailViewRenderer } from "~/components/renderers";
-import type { FormViewMeta } from "~/components/renderers";
+import type { FormViewMeta, ActionMeta } from "~/components/renderers";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { useObjectMeta } from "~/hooks/useObjectMeta";
+import { useRecordActions } from "~/hooks/useRecordActions";
+import { isActionVisible } from "~/lib/record-actions";
import { renderRecordTitle } from "~/lib/record-title";
export default function ObjectDetailScreen() {
@@ -104,6 +106,31 @@ export default function ObjectDetailScreen() {
]);
}, [client, objectName, id, router, t]);
+ /* ---- Object actions (record_header inline, record_more overflow) ---- */
+ const allActions = useMemo(
+ () => ((meta?.actions as ActionMeta[] | undefined) ?? []).filter(isActionVisible),
+ [meta],
+ );
+ const headerActions = useMemo(
+ () =>
+ allActions.filter(
+ (a) => !a.locations || a.locations.includes("record_header"),
+ ),
+ [allActions],
+ );
+ const moreActions = useMemo(
+ () => allActions.filter((a) => a.locations?.includes("record_more")),
+ [allActions],
+ );
+
+ const { runAction, busyName, modals } = useRecordActions({
+ client,
+ objectName: objectName!,
+ recordId: id!,
+ record,
+ onRefresh: fetchRecord,
+ });
+
return (
@@ -119,12 +146,17 @@ export default function ObjectDetailScreen() {
router.push(`/(app)/${appName}/${objectName}/${id}/edit` as any)
}
onDelete={handleDelete}
+ actions={headerActions}
+ moreActions={moreActions}
+ onAction={runAction}
+ busyActionName={busyName}
onPrevious={handlePrevious}
onNext={handleNext}
hasPrevious={hasPrevious}
hasNext={hasNext}
positionLabel={positionLabel}
/>
+ {modals}
);
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 9ae60c6..003e013 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,4 +1,5 @@
import "../global.css";
+import "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation()
import { useCallback, useEffect, useMemo } from "react";
import { Stack, useRouter, useSegments } from "expo-router";
@@ -11,6 +12,7 @@ import { authClient } from "~/lib/auth-client";
import { createObjectStackClient } from "~/lib/objectstack";
import { useServerStore } from "~/stores/server-store";
import { usePushNotifications } from "~/hooks/usePushNotifications";
+import { ToastProvider } from "~/components/ui/Toast";
const queryClient = new QueryClient();
@@ -95,14 +97,16 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/components/renderers/DetailViewRenderer.tsx b/components/renderers/DetailViewRenderer.tsx
index 7bf1fc1..463e6ae 100644
--- a/components/renderers/DetailViewRenderer.tsx
+++ b/components/renderers/DetailViewRenderer.tsx
@@ -1,9 +1,18 @@
-import React, { useMemo } from "react";
-import { View, Text, ScrollView, Pressable } from "react-native";
-import { Edit, Trash2, ChevronLeft, ChevronRight, AlertCircle } from "lucide-react-native";
+import React, { useMemo, useState } from "react";
+import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import {
+ Edit,
+ Trash2,
+ ChevronLeft,
+ ChevronRight,
+ AlertCircle,
+ MoreHorizontal,
+} from "lucide-react-native";
import { cn } from "~/lib/utils";
+import { getIcon } from "~/lib/getIcon";
import { EmptyState } from "~/components/common/EmptyState";
import { Button } from "~/components/ui/Button";
+import { BottomSheet } from "~/components/ui/BottomSheet";
import { Skeleton } from "~/components/ui/Skeleton";
import { FieldRenderer } from "./fields/FieldRenderer";
import type { FieldDefinition, FormViewMeta, FormSection, ActionMeta } from "./types";
@@ -54,8 +63,12 @@ export interface DetailViewRendererProps {
onDelete?: () => void;
/** Custom action handler */
onAction?: (action: ActionMeta) => void;
- /** Available actions */
+ /** Header (`record_header`) object actions, rendered as inline buttons. */
actions?: ActionMeta[];
+ /** Overflow (`record_more`) object actions, rendered in a "⋯" menu. */
+ moreActions?: ActionMeta[];
+ /** Name of the action currently executing (drives the per-button spinner). */
+ busyActionName?: string | null;
/** Related records by relationship name */
relatedLists?: RelatedListConfig[];
/** Handler when a related record is pressed */
@@ -105,13 +118,81 @@ const SYSTEM_FIELDS = new Set([
/* Action Bar */
/* ------------------------------------------------------------------ */
+/** Per-variant container + label classes for a header action button. */
+const ACTION_VARIANT_BG: Record = {
+ primary: "bg-primary active:opacity-80",
+ danger: "bg-destructive active:opacity-80",
+ secondary: "border border-border active:bg-muted",
+ ghost: "active:bg-muted",
+ link: "active:opacity-60",
+};
+const ACTION_VARIANT_TEXT: Record = {
+ primary: "text-primary-foreground",
+ danger: "text-destructive-foreground",
+ secondary: "text-foreground",
+ ghost: "text-foreground",
+ link: "text-primary",
+};
+/** Icon tint per variant (lucide needs an explicit color, not a class). */
+const ACTION_VARIANT_ICON: Record = {
+ primary: "#ffffff",
+ danger: "#ffffff",
+ secondary: "#0f172a",
+ ghost: "#0f172a",
+ link: "#1e40af",
+};
+
+function HeaderActionButton({
+ action,
+ onAction,
+ busy,
+}: {
+ action: ActionMeta;
+ onAction?: (action: ActionMeta) => void;
+ busy: boolean;
+}) {
+ const variant = action.variant ?? "secondary";
+ const Icon = action.icon ? getIcon(action.icon) : null;
+ return (
+ !busy && onAction?.(action)}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={action.label}
+ accessibilityState={{ busy }}
+ >
+ {busy ? (
+
+ ) : Icon ? (
+
+ ) : null}
+
+ {action.label}
+
+
+ );
+}
+
function DetailActionBar({
onEdit,
onDelete,
actions,
+ moreActions,
onAction,
-}: Pick) {
- const hasActions = onEdit || onDelete || (actions && actions.length > 0);
+ busyActionName,
+}: Pick<
+ DetailViewRendererProps,
+ "onEdit" | "onDelete" | "actions" | "moreActions" | "onAction" | "busyActionName"
+>) {
+ const [moreOpen, setMoreOpen] = useState(false);
+ const hasMore = !!(moreActions && moreActions.length > 0);
+ const hasActions =
+ onEdit || onDelete || (actions && actions.length > 0) || hasMore;
if (!hasActions) return null;
return (
@@ -139,16 +220,64 @@ function DetailActionBar({
)}
{actions?.map((action) => (
- onAction?.(action)}
- accessibilityRole="button"
- accessibilityLabel={action.label}
- >
- {action.label}
-
+ action={action}
+ onAction={onAction}
+ busy={busyActionName === action.name}
+ />
))}
+
+ {hasMore && (
+ <>
+ setMoreOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel="More actions"
+ >
+
+
+
+
+ {moreActions!.map((action) => {
+ const Icon = action.icon ? getIcon(action.icon) : null;
+ const busy = busyActionName === action.name;
+ const danger = action.variant === "danger";
+ return (
+ {
+ if (busy) return;
+ setMoreOpen(false);
+ onAction?.(action);
+ }}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={action.label}
+ >
+ {busy ? (
+
+ ) : Icon ? (
+
+ ) : (
+
+ )}
+
+ {action.label}
+
+
+ );
+ })}
+
+ >
+ )}
);
}
@@ -278,6 +407,8 @@ export function DetailViewRenderer({
onDelete,
onAction,
actions,
+ moreActions,
+ busyActionName,
relatedLists,
onRelatedRecordPress,
onPrevious,
@@ -356,7 +487,9 @@ export function DetailViewRenderer({
onEdit={allowEdit ? onEdit : undefined}
onDelete={allowDelete ? onDelete : undefined}
actions={actions}
+ moreActions={moreActions}
onAction={onAction}
+ busyActionName={busyActionName}
/>
{/* Record navigation (previous / next) */}
diff --git a/components/renderers/types.ts b/components/renderers/types.ts
index 9653179..0ff01f8 100644
--- a/components/renderers/types.ts
+++ b/components/renderers/types.ts
@@ -211,6 +211,8 @@ export interface ActionMeta {
| "global_nav"
)[];
component?: "action:button" | "action:icon" | "action:menu" | "action:group";
+ /** Spec `Action.variant` — visual emphasis for the rendered control. */
+ variant?: "link" | "primary" | "secondary" | "danger" | "ghost";
confirmText?: string;
successMessage?: string;
refreshAfter?: boolean;
diff --git a/hooks/useRecordActions.tsx b/hooks/useRecordActions.tsx
new file mode 100644
index 0000000..72888b6
--- /dev/null
+++ b/hooks/useRecordActions.tsx
@@ -0,0 +1,311 @@
+/**
+ * Drives ObjectStack object actions on a record screen.
+ *
+ * Wires the framework-agnostic {@link runRecordAction} engine to on-device UI:
+ * a native confirm (Alert), a parameter-collection BottomSheet, a result-reveal
+ * dialog, and toasts. Returns `runAction` to invoke an action, a `busyName` for
+ * 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 { useRouter } from "expo-router";
+import { useTranslation } from "react-i18next";
+import type { ObjectStackClient } from "@objectstack/client";
+import { BottomSheet } from "~/components/ui/BottomSheet";
+import { Button } from "~/components/ui/Button";
+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 type { ActionMeta, ActionParamMeta } from "~/components/renderers/types";
+import { runRecordAction, type ActionRunContext } from "~/lib/record-actions";
+
+interface ParamState {
+ action: ActionMeta;
+ params: ActionParamMeta[];
+ resolve: (values: Record | null) => void;
+}
+
+interface ResultState {
+ action: ActionMeta;
+ data: unknown;
+ resolve: () => void;
+}
+
+export interface UseRecordActionsArgs {
+ client: ObjectStackClient;
+ objectName: string;
+ recordId: string;
+ record: Record | null;
+ onRefresh: () => void;
+}
+
+export interface UseRecordActions {
+ runAction: (action: ActionMeta) => void;
+ busyName: string | null;
+ modals: React.ReactNode;
+}
+
+/** Numeric-ish field types that should use a numeric keyboard. */
+const NUMERIC_TYPES = new Set(["number", "currency", "percent", "rating", "slider"]);
+
+/** Read a possibly-dotted path out of an arbitrary value. */
+function valueAtPath(data: unknown, path: string): unknown {
+ return path.split(".").reduce((acc, key) => {
+ if (acc && typeof acc === "object") return (acc as Record)[key];
+ return undefined;
+ }, data);
+}
+
+function formatValue(value: unknown, format?: string): string {
+ if (value == null) return "—";
+ if (format === "json") return JSON.stringify(value, null, 2);
+ if (typeof value === "object") return JSON.stringify(value, null, 2);
+ return String(value);
+}
+
+export function useRecordActions({
+ client,
+ objectName,
+ recordId,
+ record,
+ onRefresh,
+}: UseRecordActionsArgs): UseRecordActions {
+ const router = useRouter();
+ const { t } = useTranslation();
+ const { toastSuccess, toastError } = useToast();
+
+ const [busyName, setBusyName] = React.useState(null);
+ const [paramState, setParamState] = React.useState(null);
+ const [paramValues, setParamValues] = React.useState>({});
+ const [resultState, setResultState] = React.useState(null);
+
+ 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],
+ );
+
+ const collectParams = React.useCallback(
+ (params: ActionParamMeta[], action: ActionMeta) =>
+ new Promise | null>((resolve) => {
+ // Seed defaults so untouched optional fields submit cleanly.
+ const seed: Record = {};
+ for (const p of params) {
+ if (p.type === "boolean" || p.type === "toggle") seed[p.name] = false;
+ }
+ setParamValues(seed);
+ setParamState({ action, params, resolve });
+ }),
+ [],
+ );
+
+ const showResult = React.useCallback(
+ (action: ActionMeta, data: unknown) =>
+ new Promise((resolve) => {
+ setResultState({ action, data, resolve });
+ }),
+ [],
+ );
+
+ const ctx = React.useMemo(
+ () => ({
+ client,
+ objectName,
+ recordId,
+ record,
+ confirm,
+ collectParams,
+ showResult,
+ toast: (message, variant) =>
+ variant === "error"
+ ? toastError(message || t("actions.failed"))
+ : toastSuccess(message || t("actions.completed")),
+ navigate: (url) => router.push(url as never),
+ onRefresh,
+ }),
+ [
+ client,
+ objectName,
+ recordId,
+ record,
+ confirm,
+ collectParams,
+ showResult,
+ toastError,
+ toastSuccess,
+ router,
+ onRefresh,
+ t,
+ ],
+ );
+
+ const runAction = React.useCallback(
+ (action: ActionMeta) => {
+ setBusyName(action.name);
+ void runRecordAction(action, ctx).finally(() => setBusyName(null));
+ },
+ [ctx],
+ );
+
+ /* ---- Param-collection submit/cancel ---- */
+ const submitParams = React.useCallback(() => {
+ const state = paramState;
+ if (!state) return;
+ // Enforce required fields.
+ const missing = state.params.find(
+ (p) => p.required && (paramValues[p.name] == null || paramValues[p.name] === ""),
+ );
+ if (missing) {
+ Alert.alert("", t("actions.required", { field: missing.label }));
+ return;
+ }
+ setParamState(null);
+ state.resolve(paramValues);
+ }, [paramState, paramValues, t]);
+
+ const cancelParams = React.useCallback(() => {
+ const state = paramState;
+ setParamState(null);
+ state?.resolve(null);
+ }, [paramState]);
+
+ const acknowledgeResult = React.useCallback(() => {
+ const state = resultState;
+ setResultState(null);
+ state?.resolve();
+ }, [resultState]);
+
+ /* ---- Modals ---- */
+ const modals = (
+ <>
+ {
+ if (!o) cancelParams();
+ }}
+ title={paramState?.action.label}
+ >
+
+ {paramState?.params.map((param) => (
+
+
+ {param.label}
+ {param.required ? * : null}
+
+ {param.type === "boolean" || param.type === "toggle" ? (
+
+ setParamValues((prev) => ({ ...prev, [param.name]: v }))
+ }
+ />
+ ) : (param.type === "select" || param.type === "radio") && param.options ? (
+
+ ))}
+
+
+ {t("common.cancel")}
+
+
+ {paramState?.action.label ?? t("actions.submit")}
+
+
+
+
+
+
+
+
+
+ {resultState?.action.resultDialog?.title ?? resultState?.action.label}
+
+ {resultState?.action.resultDialog?.description ? (
+
+ {resultState.action.resultDialog.description}
+
+ ) : null}
+
+ {resultState?.action.resultDialog?.fields &&
+ resultState.action.resultDialog.fields.length > 0 ? (
+ resultState.action.resultDialog.fields.map((f) => (
+
+
+ {f.label ?? f.path}
+
+
+ {formatValue(valueAtPath(resultState.data, f.path), f.format)}
+
+
+ ))
+ ) : (
+
+ {formatValue(resultState?.data, resultState?.action.resultDialog?.format)}
+
+ )}
+
+
+ {resultState?.action.resultDialog?.acknowledge ?? t("common.done")}
+
+
+
+
+ >
+ );
+
+ return { runAction, busyName, modals };
+}
diff --git a/lib/objectstack.ts b/lib/objectstack.ts
index f75035b..e1f667b 100644
--- a/lib/objectstack.ts
+++ b/lib/objectstack.ts
@@ -30,6 +30,16 @@ export function setObjectStackApiUrl(url: string) {
API_URL = url;
}
+/**
+ * The currently-configured API base URL (e.g. `https://cloud.objectos.app`).
+ * Used by features that call backend routes the typed SDK client doesn't
+ * surface — notably object-action execution (the `/api/v1/automation/.../trigger`
+ * and `/api/v1/actions/...` routes).
+ */
+export function getApiUrl(): string {
+ return API_URL;
+}
+
/**
* The scheme+host origin of the server (e.g. `https://cloud.objectos.app`),
* derived from the configured base URL.
@@ -95,6 +105,21 @@ export function createObjectStackClient(token?: string): ObjectStackClient {
});
}
+/**
+ * Authenticated `fetch` for backend routes outside the typed SDK surface.
+ * Accepts an absolute URL or a base-relative path (e.g. `/api/v1/...`) and
+ * carries the better-auth session + CSRF origin exactly like SDK data calls.
+ * Used for object-action execution (the `/api/v1/automation/.../trigger` and
+ * `/api/v1/actions/...` routes).
+ */
+export function apiFetch(pathOrUrl: string, init?: RequestInit): Promise {
+ const base = API_URL.replace(/\/+$/, "");
+ const url = /^https?:\/\//.test(pathOrUrl)
+ ? pathOrUrl
+ : `${base}${pathOrUrl.startsWith("/") ? "" : "/"}${pathOrUrl}`;
+ return authAwareFetch(url, init);
+}
+
/**
* Get a singleton client for unauthenticated/discovery requests.
* For authenticated requests, use the provider which creates a token-aware client.
diff --git a/lib/record-actions.ts b/lib/record-actions.ts
new file mode 100644
index 0000000..5edb0d1
--- /dev/null
+++ b/lib/record-actions.ts
@@ -0,0 +1,273 @@
+/**
+ * Record-action execution engine.
+ *
+ * Mirrors the web `@object-ui/core` ActionRunner lifecycle for ObjectStack
+ * object actions (the `actions[]` array on an object's metadata), adapted for
+ * React Native. An action runs through:
+ *
+ * 1. confirm — if `confirmText` is set, ask the user first
+ * 2. params — if `params[]` is defined, collect input via a dialog
+ * 3. dispatch — by `type`:
+ * • url → open external link / in-app navigation
+ * • flow → POST /api/v1/automation/{target}/trigger
+ * • api → SDK `data.update`, or POST when target is a path
+ * • script | modal | form
+ * → POST /api/v1/actions/{object}/{target}
+ * 4. post — toast success/error, reveal `resultDialog`, refresh record
+ *
+ * Side-effecting concerns (confirm, param collection, result reveal, toast,
+ * navigation, refresh) are injected via {@link ActionRunContext} so this stays
+ * UI-framework-agnostic and unit-testable.
+ */
+import { Linking } from "react-native";
+import type { ObjectStackClient } from "@objectstack/client";
+import { apiFetch } from "~/lib/objectstack";
+import type { ActionMeta, ActionParamMeta } from "~/components/renderers/types";
+
+export interface ActionResult {
+ success: boolean;
+ data?: unknown;
+ error?: string;
+ /** Whether the caller should re-fetch the record afterwards. */
+ reload?: boolean;
+}
+
+export interface ActionRunContext {
+ client: ObjectStackClient;
+ objectName: string;
+ recordId: string;
+ record: Record | null;
+ /** Ask the user to confirm a destructive/irreversible action. */
+ confirm: (message: string) => Promise;
+ /** Collect parameter values; resolves `null` when the user cancels. */
+ collectParams: (
+ params: ActionParamMeta[],
+ action: ActionMeta,
+ ) => Promise | null>;
+ /** Reveal the action's response; resolves once acknowledged. */
+ showResult: (action: ActionMeta, data: unknown) => Promise;
+ /** Surface a success/error toast. */
+ toast: (message: string, variant: "success" | "error") => void;
+ /** Navigate to an in-app route. */
+ navigate: (url: string) => void;
+ /** Re-fetch the current record (after a mutating action). */
+ onRefresh: () => void;
+}
+
+/**
+ * An action is hidden only when its `visible` expression is the literal
+ * `"false"`. We can't safely evaluate arbitrary CEL/JS on-device, so anything
+ * else is treated as visible (the server still enforces real permissions).
+ */
+export function isActionVisible(action: ActionMeta): boolean {
+ return action.visible?.trim() !== "false";
+}
+
+/** Substitute `${record.x}`, `${param.x}`, `${recordId}`, `${objectName}`. */
+function interpolate(
+ template: string,
+ params: Record,
+ ctx: ActionRunContext,
+ encode: boolean,
+): string {
+ return template.replace(/\$\{([^}]+)\}/g, (_match, rawExpr: string) => {
+ const expr = rawExpr.trim();
+ let value: unknown;
+ if (expr === "recordId") value = ctx.recordId;
+ else if (expr === "objectName") value = ctx.objectName;
+ else if (expr.startsWith("record.")) value = ctx.record?.[expr.slice(7)];
+ else if (expr.startsWith("param.")) value = params[expr.slice(6)];
+ else if (expr.startsWith("ctx.")) {
+ value = expr === "ctx.recordId" ? ctx.recordId : undefined;
+ }
+ if (value == null) return "";
+ const str = String(value);
+ return encode ? encodeURIComponent(str) : str;
+ });
+}
+
+function isUrlLike(value: string): boolean {
+ return /^https?:\/\//.test(value);
+}
+
+async function readJson(res: Response): Promise | null> {
+ try {
+ return (await res.json()) as Record;
+ } catch {
+ return null;
+ }
+}
+
+/* ---- Per-type dispatch ------------------------------------------------- */
+
+async function dispatchUrl(
+ action: ActionMeta,
+ params: Record,
+ ctx: ActionRunContext,
+): Promise {
+ const raw = action.target ?? action.execute;
+ if (!raw) return { success: false, error: "No URL provided for url action" };
+ const url = interpolate(raw, params, ctx, true);
+ if (isUrlLike(url)) {
+ await Linking.openURL(url);
+ } else {
+ ctx.navigate(url);
+ }
+ return { success: true };
+}
+
+async function dispatchFlow(
+ action: ActionMeta,
+ params: Record,
+ ctx: ActionRunContext,
+): Promise {
+ const flowName = action.target ?? action.name;
+ if (!flowName) return { success: false, error: "No flow target provided" };
+ const res = await apiFetch(
+ `/api/v1/automation/${encodeURIComponent(flowName)}/trigger`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ recordId: ctx.recordId,
+ objectName: ctx.objectName,
+ params,
+ }),
+ },
+ );
+ const json = await readJson(res);
+ if (!res.ok || json?.success === false) {
+ return {
+ success: false,
+ error: (json?.error as string) ?? `Flow "${flowName}" failed (HTTP ${res.status})`,
+ };
+ }
+ return { success: true, data: json?.data, reload: action.refreshAfter !== false };
+}
+
+async function dispatchApi(
+ action: ActionMeta,
+ params: Record,
+ ctx: ActionRunContext,
+): Promise {
+ const target = action.target ?? action.name;
+ // A path/URL target is a raw endpoint; anything else updates the record.
+ if (target && (target.startsWith("/") || isUrlLike(target))) {
+ const url = interpolate(target, params, ctx, false);
+ const res = await apiFetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(params),
+ });
+ const json = await readJson(res);
+ if (!res.ok || json?.success === false) {
+ return {
+ success: false,
+ error: (json?.error as string) ?? `Request failed (HTTP ${res.status})`,
+ };
+ }
+ return { success: true, data: json?.data, reload: action.refreshAfter !== false };
+ }
+ // Generic record mutation with the collected params.
+ if (Object.keys(params).length > 0) {
+ await ctx.client.data.update(ctx.objectName, ctx.recordId, params);
+ }
+ return { success: true, reload: action.refreshAfter !== false };
+}
+
+async function dispatchServerAction(
+ action: ActionMeta,
+ params: Record,
+ ctx: ActionRunContext,
+): Promise {
+ const target = action.target ?? action.name;
+ if (!target) return { success: false, error: "No action target provided" };
+ const res = await apiFetch(
+ `/api/v1/actions/${encodeURIComponent(ctx.objectName)}/${encodeURIComponent(target)}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ recordId: ctx.recordId, params }),
+ },
+ );
+ const json = await readJson(res);
+ if (!res.ok || json?.success === false) {
+ return {
+ success: false,
+ error: (json?.error as string) ?? `Action "${target}" failed (HTTP ${res.status})`,
+ };
+ }
+ return { success: true, data: json?.data, reload: action.refreshAfter === true };
+}
+
+/* ---- Lifecycle --------------------------------------------------------- */
+
+/**
+ * Run a single object action end-to-end. Returns the result; all user-facing
+ * feedback (toasts, dialogs, refresh) is emitted through `ctx` along the way.
+ */
+export async function runRecordAction(
+ action: ActionMeta,
+ ctx: ActionRunContext,
+): Promise {
+ // 1. Confirmation
+ if (action.confirmText) {
+ const ok = await ctx.confirm(action.confirmText);
+ if (!ok) return { success: false, error: "cancelled" };
+ }
+
+ // 2. Param collection
+ let params: Record = {};
+ if (action.params && action.params.length > 0) {
+ const collected = await ctx.collectParams(action.params, action);
+ if (collected === null) return { success: false, error: "cancelled" };
+ params = collected;
+ }
+
+ // 3. Dispatch by type
+ let result: ActionResult;
+ try {
+ switch (action.type) {
+ case "url":
+ result = await dispatchUrl(action, params, ctx);
+ break;
+ case "flow":
+ result = await dispatchFlow(action, params, ctx);
+ break;
+ case "api":
+ result = await dispatchApi(action, params, ctx);
+ break;
+ case "script":
+ case "modal":
+ result = await dispatchServerAction(action, params, ctx);
+ break;
+ default:
+ // No explicit type: treat a path/URL target as navigation, else a
+ // server-registered action.
+ result = action.target && (action.target.startsWith("/") || isUrlLike(action.target))
+ ? await dispatchUrl(action, params, ctx)
+ : await dispatchServerAction(action, params, ctx);
+ }
+ } catch (error) {
+ result = { success: false, error: (error as Error).message };
+ }
+
+ // 4. Post-execution
+ if (result.error === "cancelled") return result;
+
+ const hasResultDialog = !!(action.resultDialog && result.success);
+ if (!result.success) {
+ // Empty string → the toast layer substitutes a localized default.
+ ctx.toast(result.error || "", "error");
+ } else if (hasResultDialog) {
+ await ctx.showResult(action, result.data);
+ } else {
+ ctx.toast(action.successMessage || "", "success");
+ }
+
+ if (result.success && result.reload) {
+ ctx.onRefresh();
+ }
+
+ return result;
+}
diff --git a/locales/ar.json b/locales/ar.json
index 7d515ae..49e4da4 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -68,6 +68,14 @@
"previous": "السابق",
"next": "التالي"
},
+ "actions": {
+ "title": "إجراءات",
+ "moreActions": "المزيد من الإجراءات",
+ "required": "{{field}} مطلوب",
+ "submit": "إرسال",
+ "completed": "اكتمل الإجراء",
+ "failed": "فشل الإجراء"
+ },
"batch": {
"selected": "{{count}} محدد",
"deleteSelected": "حذف المحدد",
diff --git a/locales/en.json b/locales/en.json
index dcd5ca3..8cbaeda 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -64,6 +64,14 @@
"previous": "Previous",
"next": "Next"
},
+ "actions": {
+ "title": "Actions",
+ "moreActions": "More actions",
+ "required": "{{field}} is required",
+ "submit": "Submit",
+ "completed": "Action completed",
+ "failed": "Action failed"
+ },
"batch": {
"selected": "{{count}} selected",
"deleteSelected": "Delete Selected",
diff --git a/locales/zh.json b/locales/zh.json
index 4f4f42c..73a1987 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -63,6 +63,14 @@
"previous": "上一条",
"next": "下一条"
},
+ "actions": {
+ "title": "操作",
+ "moreActions": "更多操作",
+ "required": "{{field}} 为必填项",
+ "submit": "提交",
+ "completed": "操作完成",
+ "failed": "操作失败"
+ },
"batch": {
"selected": "已选 {{count}} 项",
"deleteSelected": "删除选中",