Skip to content

Commit d786ff3

Browse files
Merge pull request #61 from objectstack-ai/ux/mobile-polish
Polish UX across all screens, renderers, and UI primitives
2 parents 4e64e76 + b8097ca commit d786ff3

55 files changed

Lines changed: 1900 additions & 482 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/(app)/[appName]/[objectName]/[id].tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { useClient, useQuery, useView } from "@objectstack/client-react";
55
import { useTranslation } from "react-i18next";
66
import { useEffect, useState, useCallback, useMemo } from "react";
77
import { DetailViewRenderer } from "~/components/renderers";
8-
import type { FormViewMeta } from "~/components/renderers";
8+
import type { FormViewMeta, ActionMeta } from "~/components/renderers";
99
import { ScreenHeader } from "~/components/common/ScreenHeader";
1010
import { useObjectMeta } from "~/hooks/useObjectMeta";
11+
import { useRecordActions } from "~/hooks/useRecordActions";
12+
import { isActionVisible } from "~/lib/record-actions";
1113
import { renderRecordTitle } from "~/lib/record-title";
1214

1315
export default function ObjectDetailScreen() {
@@ -104,6 +106,31 @@ export default function ObjectDetailScreen() {
104106
]);
105107
}, [client, objectName, id, router, t]);
106108

109+
/* ---- Object actions (record_header inline, record_more overflow) ---- */
110+
const allActions = useMemo<ActionMeta[]>(
111+
() => ((meta?.actions as ActionMeta[] | undefined) ?? []).filter(isActionVisible),
112+
[meta],
113+
);
114+
const headerActions = useMemo(
115+
() =>
116+
allActions.filter(
117+
(a) => !a.locations || a.locations.includes("record_header"),
118+
),
119+
[allActions],
120+
);
121+
const moreActions = useMemo(
122+
() => allActions.filter((a) => a.locations?.includes("record_more")),
123+
[allActions],
124+
);
125+
126+
const { runAction, busyName, modals } = useRecordActions({
127+
client,
128+
objectName: objectName!,
129+
recordId: id!,
130+
record,
131+
onRefresh: fetchRecord,
132+
});
133+
107134
return (
108135
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
109136
<ScreenHeader title={String(displayName)} subtitle={positionLabel} />
@@ -119,12 +146,17 @@ export default function ObjectDetailScreen() {
119146
router.push(`/(app)/${appName}/${objectName}/${id}/edit` as any)
120147
}
121148
onDelete={handleDelete}
149+
actions={headerActions}
150+
moreActions={moreActions}
151+
onAction={runAction}
152+
busyActionName={busyName}
122153
onPrevious={handlePrevious}
123154
onNext={handleNext}
124155
hasPrevious={hasPrevious}
125156
hasNext={hasNext}
126157
positionLabel={positionLabel}
127158
/>
159+
{modals}
128160
</SafeAreaView>
129161
);
130162
}

app/(app)/[appName]/[objectName]/[id]/edit.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { SafeAreaView } from "react-native-safe-area-context";
2-
import { View, Text, ActivityIndicator, Pressable } from "react-native";
2+
import { View } from "react-native";
33
import { useLocalSearchParams, useRouter } from "expo-router";
44
import { useClient, useMutation } from "@objectstack/client-react";
55
import { useEffect, useState, useCallback } from "react";
6+
import { AlertCircle } from "lucide-react-native";
67
import { FormViewRenderer } from "~/components/renderers";
78
import { ScreenHeader } from "~/components/common/ScreenHeader";
9+
import { EmptyState } from "~/components/ui/EmptyState";
10+
import { ListSkeleton } from "~/components/ui/ListSkeleton";
811
import { useObjectMeta } from "~/hooks/useObjectMeta";
912

1013
export default function EditRecordScreen() {
@@ -55,8 +58,8 @@ export default function EditRecordScreen() {
5558
return (
5659
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
5760
<ScreenHeader title={`Edit ${displayName}`} />
58-
<View className="flex-1 items-center justify-center">
59-
<ActivityIndicator size="large" color="#1e40af" />
61+
<View className="px-4 pt-4">
62+
<ListSkeleton count={5} />
6063
</View>
6164
</SafeAreaView>
6265
);
@@ -66,15 +69,14 @@ export default function EditRecordScreen() {
6669
return (
6770
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
6871
<ScreenHeader title={`Edit ${displayName}`} />
69-
<View className="flex-1 items-center justify-center px-6">
70-
<Text className="text-base text-destructive">{loadError}</Text>
71-
<Pressable
72-
className="mt-4 rounded-xl bg-primary px-5 py-3"
73-
onPress={fetchRecord}
74-
>
75-
<Text className="font-semibold text-primary-foreground">Retry</Text>
76-
</Pressable>
77-
</View>
72+
<EmptyState
73+
icon={AlertCircle}
74+
variant="error"
75+
title="Couldn't Load Record"
76+
description={loadError}
77+
actionLabel="Retry"
78+
onAction={fetchRecord}
79+
/>
7880
</SafeAreaView>
7981
);
8082
}

app/(app)/[appName]/index.tsx

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { View, Text, ScrollView, Pressable, ActivityIndicator, Linking } from "react-native";
1+
import { View, Text, ScrollView, Linking } from "react-native";
22
import { SafeAreaView } from "react-native-safe-area-context";
33
import { useLocalSearchParams, useRouter } from "expo-router";
4-
import { Inbox, ChevronRight } from "lucide-react-native";
5-
import { Card, CardContent } from "~/components/ui/Card";
4+
import { Inbox, ChevronRight, AlertCircle } from "lucide-react-native";
5+
import { PressableCard } from "~/components/ui/PressableCard";
6+
import { EmptyState } from "~/components/ui/EmptyState";
7+
import { ListSkeleton } from "~/components/ui/ListSkeleton";
68
import { ScreenHeader } from "~/components/common/ScreenHeader";
79
import { useApp, type NavigationItem } from "~/hooks/useApps";
810
import { getIcon } from "~/lib/getIcon";
@@ -59,24 +61,23 @@ export default function AppHomeScreen() {
5961
const Icon = getIcon(item.icon);
6062
const navigable = isNavigable(item);
6163
return (
62-
<Pressable
64+
<PressableCard
6365
key={item.id}
6466
disabled={!navigable}
67+
haptic={navigable}
6568
onPress={() => navigate(item)}
66-
className={navigable ? "" : "opacity-40"}
69+
className={`flex-row items-center p-3.5 ${navigable ? "" : "opacity-40"}`}
70+
accessibilityRole={navigable ? "button" : undefined}
71+
accessibilityLabel={item.label}
6772
>
68-
<Card>
69-
<CardContent className="flex-row items-center py-3.5">
70-
<View className="rounded-xl bg-primary/10 p-2.5">
71-
<Icon size={20} color="#1e40af" />
72-
</View>
73-
<Text className="ml-3 flex-1 text-base font-medium text-card-foreground">
74-
{item.label}
75-
</Text>
76-
{navigable ? <ChevronRight size={18} color="#94a3b8" /> : null}
77-
</CardContent>
78-
</Card>
79-
</Pressable>
73+
<View className="rounded-xl bg-primary/10 p-2.5">
74+
<Icon size={20} color="#1e40af" />
75+
</View>
76+
<Text className="ml-3 flex-1 text-base font-medium text-card-foreground">
77+
{item.label}
78+
</Text>
79+
{navigable ? <ChevronRight size={18} color="#94a3b8" /> : null}
80+
</PressableCard>
8081
);
8182
};
8283

@@ -105,22 +106,25 @@ export default function AppHomeScreen() {
105106
<ScreenHeader title={displayName} backFallback="/(tabs)/apps" />
106107
<ScrollView className="flex-1" contentContainerClassName="px-5 pb-8 pt-2">
107108
{isLoading ? (
108-
<View className="flex-1 items-center justify-center pt-20">
109-
<ActivityIndicator size="large" color="#1e40af" />
109+
<View className="pt-3">
110+
<ListSkeleton count={6} />
110111
</View>
111112
) : error ? (
112-
<View className="flex-1 items-center justify-center pt-20">
113-
<Text className="text-base text-destructive">{error.message}</Text>
113+
<View className="pt-20">
114+
<EmptyState
115+
icon={AlertCircle}
116+
variant="error"
117+
title="Couldn't Load App"
118+
description={error.message}
119+
/>
114120
</View>
115121
) : navigation.length === 0 ? (
116-
<View className="flex-1 items-center justify-center pt-20">
117-
<View className="rounded-2xl bg-muted p-6">
118-
<Inbox size={40} color="#94a3b8" />
119-
</View>
120-
<Text className="mt-5 text-lg font-semibold text-foreground">No Navigation</Text>
121-
<Text className="mt-2 text-center text-sm text-muted-foreground">
122-
This app hasn&apos;t published a navigation menu yet.
123-
</Text>
122+
<View className="pt-20">
123+
<EmptyState
124+
icon={Inbox}
125+
title="No Navigation"
126+
description="This app hasn't published a navigation menu yet."
127+
/>
124128
</View>
125129
) : (
126130
<View>

app/(app)/packages.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import {
44
Text,
55
ScrollView,
66
TouchableOpacity,
7-
ActivityIndicator,
87
Alert,
98
} from "react-native";
109
import { SafeAreaView } from "react-native-safe-area-context";
1110
import { Package, ToggleLeft, ToggleRight, Trash2 } from "lucide-react-native";
1211
import { usePackageManagement } from "~/hooks/usePackageManagement";
1312
import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/Card";
1413
import { ScreenHeader } from "~/components/common/ScreenHeader";
14+
import { EmptyState } from "~/components/ui/EmptyState";
15+
import { ListSkeleton } from "~/components/ui/ListSkeleton";
1516

1617
/**
1718
* Package management screen – list, enable, disable, uninstall packages.
@@ -58,31 +59,29 @@ export default function PackagesScreen() {
5859
return (
5960
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
6061
<ScreenHeader title="Packages" />
61-
<ScrollView className="flex-1 bg-background">
62+
<ScrollView className="flex-1 bg-background" contentContainerClassName="pb-4">
6263
{isLoading && !packages.length ? (
63-
<View className="flex-1 items-center justify-center py-20">
64-
<ActivityIndicator size="large" color="#1e40af" />
64+
<View className="p-4">
65+
<ListSkeleton count={5} />
6566
</View>
6667
) : error ? (
67-
<View className="flex-1 items-center justify-center px-6 py-20">
68-
<Text className="text-destructive text-center mb-4">
69-
{error.message}
70-
</Text>
71-
<TouchableOpacity
72-
onPress={refetch}
73-
className="bg-primary px-4 py-2 rounded-lg"
74-
>
75-
<Text className="text-primary-foreground font-medium">
76-
Retry
77-
</Text>
78-
</TouchableOpacity>
68+
<View className="pt-24">
69+
<EmptyState
70+
icon={Package}
71+
variant="error"
72+
title="Couldn't Load Packages"
73+
description={error.message}
74+
actionLabel="Retry"
75+
onAction={refetch}
76+
/>
7977
</View>
8078
) : !packages.length ? (
81-
<View className="flex-1 items-center justify-center px-6 py-20">
82-
<Package size={48} color="#9ca3af" />
83-
<Text className="text-muted-foreground mt-4">
84-
No packages installed
85-
</Text>
79+
<View className="pt-24">
80+
<EmptyState
81+
icon={Package}
82+
title="No Packages"
83+
description="No packages are installed yet."
84+
/>
8685
</View>
8786
) : (
8887
<View className="p-4 gap-3">
@@ -97,6 +96,10 @@ export default function PackagesScreen() {
9796
<View className="flex-row items-center gap-3">
9897
<TouchableOpacity
9998
onPress={() => handleToggle(pkg.id, pkg.enabled)}
99+
hitSlop={8}
100+
accessibilityRole="switch"
101+
accessibilityState={{ checked: pkg.enabled }}
102+
accessibilityLabel={`${pkg.enabled ? "Disable" : "Enable"} ${pkg.label}`}
100103
>
101104
{pkg.enabled ? (
102105
<ToggleRight size={24} color="#16a34a" />
@@ -106,6 +109,9 @@ export default function PackagesScreen() {
106109
</TouchableOpacity>
107110
<TouchableOpacity
108111
onPress={() => handleUninstall(pkg.id, pkg.label)}
112+
hitSlop={8}
113+
accessibilityRole="button"
114+
accessibilityLabel={`Uninstall ${pkg.label}`}
109115
>
110116
<Trash2 size={18} color="#dc2626" />
111117
</TouchableOpacity>

app/(app)/page/[id].tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React, { useEffect, useState } from "react";
2-
import { View, Text } from "react-native";
32
import { SafeAreaView } from "react-native-safe-area-context";
43
import { useLocalSearchParams } from "expo-router";
54
import { useClient } from "@objectstack/client-react";
5+
import { AlertCircle } from "lucide-react-native";
66
import { ScreenHeader } from "~/components/common/ScreenHeader";
7+
import { EmptyState } from "~/components/ui/EmptyState";
78
import { PageRenderer } from "~/components/renderers/PageRenderer";
89
import {
910
validatePageSchema,
@@ -60,9 +61,12 @@ export default function SDUIPageScreen() {
6061
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
6162
<ScreenHeader title={schema?.label ?? id ?? "Page"} />
6263
{error && !isLoading ? (
63-
<View className="flex-1 items-center justify-center px-6">
64-
<Text className="text-destructive text-center">{error.message}</Text>
65-
</View>
64+
<EmptyState
65+
icon={AlertCircle}
66+
variant="error"
67+
title="Couldn't Load Page"
68+
description={error.message}
69+
/>
6670
) : schema ? (
6771
<PageRenderer schema={schema} isLoading={isLoading} />
6872
) : (

0 commit comments

Comments
 (0)