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/(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 e700dc7..9b200d9 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"; @@ -29,32 +30,36 @@ export default function SignInScreen() { const ssoProviders = useServerStore((s) => s.ssoProviders); 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: string) => { + setErrorMsg(null); setLoading(true); try { await authClient.signIn.social({ @@ -62,7 +67,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); } @@ -81,14 +86,18 @@ export default function SignInScreen() { > - - + + + + + Welcome back - + Sign in to your account to continue. @@ -98,18 +107,20 @@ export default function SignInScreen() { Email - - - + { + setEmail(t); + if (errorMsg) setErrorMsg(null); + }} + /> @@ -118,18 +129,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 00dd988..ecf8e5d 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"; @@ -30,37 +31,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: string) => { + setErrorMsg(null); setLoading(true); try { await authClient.signIn.social({ @@ -68,7 +73,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); } @@ -87,14 +92,18 @@ export default function SignUpScreen() { > - - + + + + + Create account - + Sign up to get started with ObjectStack. @@ -107,9 +116,14 @@ export default function SignUpScreen() { { + setName(t); + if (errorMsg) setErrorMsg(null); + }} /> @@ -120,10 +134,15 @@ export default function SignUpScreen() { { + setEmail(t); + if (errorMsg) setErrorMsg(null); + }} /> @@ -133,18 +152,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 ? ( + + ) : ( + + )} + + } /> - 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/_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/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" /> - @@ -184,11 +184,11 @@ export default function AccountScreen() { autoCapitalize="none" keyboardType="email-address" /> - @@ -217,8 +217,8 @@ export default function AccountScreen() { secureTextEntry autoCapitalize="none" /> - @@ -236,8 +236,8 @@ export default function AccountScreen() { secureTextEntry autoCapitalize="none" /> - ) : tfUri ? ( @@ -271,8 +271,8 @@ export default function AccountScreen() { keyboardType="number-pad" maxLength={6} /> - ) : ( @@ -288,8 +288,8 @@ export default function AccountScreen() { secureTextEntry autoCapitalize="none" /> - )} 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..463e6ae 100644 --- a/components/renderers/DetailViewRenderer.tsx +++ b/components/renderers/DetailViewRenderer.tsx @@ -1,10 +1,45 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native"; -import { Edit, Trash2, ChevronLeft, ChevronRight } from "lucide-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"; +/** Skeleton placeholder that mirrors the detail's stacked section/field layout. */ +function DetailSkeleton() { + return ( + + {[0, 1].map((s) => ( + + + {[0, 1, 2].map((r) => ( + + + + + ))} + + + ))} + + ); +} + /* ------------------------------------------------------------------ */ /* Props */ /* ------------------------------------------------------------------ */ @@ -28,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 */ @@ -79,21 +118,91 @@ 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 ( {onEdit && ( Edit @@ -101,22 +210,74 @@ function DetailActionBar({ )} {onDelete && ( Delete )} {actions?.map((action) => ( - onAction?.(action)} - > - {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} + + + ); + })} + + + )} ); } @@ -246,6 +407,8 @@ export function DetailViewRenderer({ onDelete, onAction, actions, + moreActions, + busyActionName, relatedLists, onRelatedRecordPress, onPrevious, @@ -289,27 +452,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 ? ( + + ) : undefined + } + /> ); } @@ -323,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/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({ )} )} 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 ? ( + + ) : 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/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/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 ? ( + + ) : 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/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 ? ( +