diff --git a/__tests__/lib/recent-store.test.ts b/__tests__/lib/recent-store.test.ts
new file mode 100644
index 0000000..aaf4a41
--- /dev/null
+++ b/__tests__/lib/recent-store.test.ts
@@ -0,0 +1,61 @@
+import { useRecentStore } from "~/stores/recent-store";
+
+const base = { appId: "todo_app", object: "todo_task" };
+
+describe("recent-store", () => {
+ beforeEach(() => {
+ useRecentStore.getState().clear();
+ });
+
+ it("tracks a viewed record at the front", () => {
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "Task A" });
+ const { records } = useRecentStore.getState();
+ expect(records).toHaveLength(1);
+ expect(records[0]).toMatchObject({ recordId: "a", title: "Task A" });
+ expect(typeof records[0].accessedAt).toBe("number");
+ });
+
+ it("most-recent-first ordering", () => {
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "A" });
+ useRecentStore.getState().track({ ...base, recordId: "b", title: "B" });
+ expect(useRecentStore.getState().records.map((r) => r.recordId)).toEqual([
+ "b",
+ "a",
+ ]);
+ });
+
+ it("re-viewing a record dedupes and moves it to the front", () => {
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "A" });
+ useRecentStore.getState().track({ ...base, recordId: "b", title: "B" });
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "A (again)" });
+ const { records } = useRecentStore.getState();
+ expect(records).toHaveLength(2);
+ expect(records.map((r) => r.recordId)).toEqual(["a", "b"]);
+ expect(records[0].title).toBe("A (again)");
+ });
+
+ it("treats the same record id under a different object as distinct", () => {
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "Task A" });
+ useRecentStore
+ .getState()
+ .track({ appId: "todo_app", object: "todo_note", recordId: "a", title: "Note A" });
+ expect(useRecentStore.getState().records).toHaveLength(2);
+ });
+
+ it("caps the history at 20 entries", () => {
+ for (let i = 0; i < 25; i++) {
+ useRecentStore.getState().track({ ...base, recordId: `r${i}`, title: `R${i}` });
+ }
+ const { records } = useRecentStore.getState();
+ expect(records).toHaveLength(20);
+ // The newest (r24) leads; the oldest five (r0–r4) fell off.
+ expect(records[0].recordId).toBe("r24");
+ expect(records.some((r) => r.recordId === "r0")).toBe(false);
+ });
+
+ it("clear empties the history", () => {
+ useRecentStore.getState().track({ ...base, recordId: "a", title: "A" });
+ useRecentStore.getState().clear();
+ expect(useRecentStore.getState().records).toHaveLength(0);
+ });
+});
diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx
index 9d7c517..6870db2 100644
--- a/app/(app)/[appName]/[objectName]/[id].tsx
+++ b/app/(app)/[appName]/[objectName]/[id].tsx
@@ -19,6 +19,7 @@ import { useToast } from "~/components/ui/Toast";
import { useConfirm } from "~/components/ui/ConfirmDialog";
import { isActionVisible } from "~/lib/record-actions";
import { renderRecordTitle } from "~/lib/record-title";
+import { useRecentStore } from "~/stores/recent-store";
export default function ObjectDetailScreen() {
const { appName, objectName, id } = useLocalSearchParams<{
@@ -72,6 +73,19 @@ export default function ObjectDetailScreen() {
const displayName = renderRecordTitle(meta, record, "Record Detail");
+ // Remember this record for the Home "Recent" section once it has loaded.
+ const trackRecent = useRecentStore((s) => s.track);
+ useEffect(() => {
+ if (!record || !objectName || !id || !appName) return;
+ trackRecent({
+ appId: appName,
+ object: objectName,
+ recordId: id,
+ title: String(displayName),
+ subtitle: meta?.label as string | undefined,
+ });
+ }, [record, objectName, id, appName, displayName, meta, trackRecent]);
+
const formView: FormViewMeta | undefined = viewData ?? undefined;
/* ---- Navigation handlers ---- */
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index e3f39a8..0ed59e5 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,5 +1,6 @@
-import { View, Text, ScrollView, RefreshControl } from "react-native";
+import { View, Text, ScrollView, Pressable, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { useColorScheme } from "nativewind";
import { webContentMaxWidth } from "~/lib/responsive";
import {
LayoutDashboard,
@@ -7,6 +8,8 @@ import {
Inbox,
AlertCircle,
Sparkles,
+ Plus,
+ Clock,
} from "lucide-react-native";
import { useClient } from "@objectstack/client-react";
import { useRouter } from "expo-router";
@@ -16,7 +19,9 @@ import { authClient } from "~/lib/auth-client";
import { PressableCard } from "~/components/ui/PressableCard";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { QuickCreateSheet } from "~/components/home/QuickCreateSheet";
import { useApps } from "~/hooks/useApps";
+import { useRecentStore } from "~/stores/recent-store";
/** Pick a time-of-day greeting i18n key from the local hour. */
function greetingKey(hour: number): "greetingMorning" | "greetingAfternoon" | "greetingEvening" {
@@ -44,8 +49,13 @@ export default function HomeScreen() {
const client = useClient();
const router = useRouter();
const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af";
const { data: session } = authClient.useSession();
const { apps, isLoading: appsLoading, refetch: refetchApps } = useApps();
+ const recents = useRecentStore((s) => s.records);
+ const clearRecents = useRecentStore((s) => s.clear);
+ const [quickCreateOpen, setQuickCreateOpen] = useState(false);
const firstName = (session?.user?.name ?? "").trim().split(/\s+/)[0];
const greeting = t(`home.${greetingKey(new Date().getHours())}`);
@@ -122,11 +132,22 @@ export default function HomeScreen() {
}
>
-
- {heading}
-
- {t("home.subtitle")}
-
+
+
+ {heading}
+
+ {t("home.subtitle")}
+
+
+ {/* Global quick-create — pick any object and open its blank form. */}
+ setQuickCreateOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={t("home.quickCreate")}
+ >
+
+
{/* AI Assistant quick entry — surfaces the assistant on the home screen
@@ -151,6 +172,60 @@ export default function HomeScreen() {
+ {/* Recently viewed records — quick re-entry to what you were working on. */}
+ {recents.length > 0 && (
+
+
+
+ {t("home.recentTitle")}
+
+
+
+ {t("home.recentClear")}
+
+
+
+
+ {recents.slice(0, 5).map((r) => (
+
+ // Dynamic route — cast, matching the other record links.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ router.push(`/(app)/${r.appId}/${r.object}/${r.recordId}` as any)
+ }
+ accessibilityRole="button"
+ accessibilityLabel={r.title}
+ >
+
+
+
+
+
+ {r.title}
+
+ {r.subtitle ? (
+
+ {r.subtitle}
+
+ ) : null}
+
+
+
+ ))}
+
+
+ )}
+
{loading ? (
) : error ? (
@@ -212,6 +287,12 @@ export default function HomeScreen() {
)}
+
+
);
}
diff --git a/components/home/QuickCreateSheet.tsx b/components/home/QuickCreateSheet.tsx
new file mode 100644
index 0000000..c077504
--- /dev/null
+++ b/components/home/QuickCreateSheet.tsx
@@ -0,0 +1,113 @@
+import React, { useMemo } from "react";
+import { View, Text, Pressable, ScrollView } from "react-native";
+import { useTranslation } from "react-i18next";
+import { useColorScheme } from "nativewind";
+import { useRouter } from "expo-router";
+import { ChevronRight } from "lucide-react-native";
+import { BottomSheet } from "~/components/ui/BottomSheet";
+import { getIcon } from "~/lib/getIcon";
+import type { AppMeta, NavigationItem } from "~/hooks/useApps";
+
+interface CreatableObject {
+ appId: string;
+ appLabel: string;
+ object: string;
+ label: string;
+ icon?: string;
+}
+
+/**
+ * Flatten every app's navigation tree into its creatable objects. `group`
+ * items are walked recursively; the first occurrence of an `app/object` pair
+ * wins (an object surfaced under several views appears once).
+ */
+function collectObjects(apps: AppMeta[]): CreatableObject[] {
+ const out: CreatableObject[] = [];
+ const seen = new Set();
+ const walk = (app: AppMeta, items: NavigationItem[]) => {
+ for (const item of items) {
+ if (item.type === "group") {
+ walk(app, item.children ?? []);
+ continue;
+ }
+ if (item.type === "object" && item.objectName) {
+ const key = `${app.name}/${item.objectName}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({
+ appId: app.name,
+ appLabel: app.label,
+ object: item.objectName,
+ label: item.label,
+ icon: item.icon,
+ });
+ }
+ }
+ };
+ apps.forEach((app) => walk(app, app.navigation ?? []));
+ return out;
+}
+
+/**
+ * Global quick-create — a sheet listing every creatable object across the
+ * installed apps. Picking one opens its blank create form.
+ */
+export function QuickCreateSheet({
+ open,
+ onOpenChange,
+ apps,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ apps: AppMeta[];
+}) {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { colorScheme } = useColorScheme();
+ const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af";
+ const objects = useMemo(() => collectObjects(apps), [apps]);
+
+ return (
+
+ {objects.length === 0 ? (
+
+ {t("home.quickCreateEmpty")}
+
+ ) : (
+
+ {objects.map((o) => {
+ const Icon = getIcon(o.icon);
+ return (
+ {
+ onOpenChange(false);
+ // Dynamic route — expo-router's typed routes can't express the
+ // computed object path, so cast (matches the detail screens).
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ router.push(`/(app)/${o.appId}/${o.object}/new` as any);
+ }}
+ >
+
+
+
+
+ {o.label}
+ {o.appLabel}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/locales/ar.json b/locales/ar.json
index b31c9f9..3dc1012 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -230,7 +230,12 @@
"dashboardsTitle": "لوحات المعلومات",
"noDashboardsTitle": "لا توجد لوحات معلومات",
"noDashboardsDesc": "لم تنشر أي من تطبيقاتك المثبتة لوحة معلومات بعد.",
- "loadErrorTitle": "تعذّر تحميل لوحات المعلومات"
+ "loadErrorTitle": "تعذّر تحميل لوحات المعلومات",
+ "recentTitle": "الأخيرة",
+ "recentClear": "مسح",
+ "quickCreate": "إنشاء",
+ "quickCreateTitle": "إنشاء سجل",
+ "quickCreateEmpty": "لا توجد كائنات متاحة للإنشاء."
},
"search": {
"title": "بحث",
diff --git a/locales/en.json b/locales/en.json
index 40f460b..83d57fb 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -227,7 +227,12 @@
"dashboardsTitle": "Dashboards",
"noDashboardsTitle": "No Dashboards",
"noDashboardsDesc": "None of your installed apps publish a dashboard yet.",
- "loadErrorTitle": "Couldn't Load Dashboards"
+ "loadErrorTitle": "Couldn't Load Dashboards",
+ "recentTitle": "Recent",
+ "recentClear": "Clear",
+ "quickCreate": "Create",
+ "quickCreateTitle": "Create record",
+ "quickCreateEmpty": "No objects available to create."
},
"search": {
"title": "Search",
diff --git a/locales/zh.json b/locales/zh.json
index 65d0fee..d49922a 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -225,7 +225,12 @@
"dashboardsTitle": "仪表盘",
"noDashboardsTitle": "暂无仪表盘",
"noDashboardsDesc": "你已安装的应用尚未发布任何仪表盘。",
- "loadErrorTitle": "无法加载仪表盘"
+ "loadErrorTitle": "无法加载仪表盘",
+ "recentTitle": "最近",
+ "recentClear": "清除",
+ "quickCreate": "新建",
+ "quickCreateTitle": "新建记录",
+ "quickCreateEmpty": "没有可创建的对象。"
},
"search": {
"title": "搜索",
diff --git a/stores/recent-store.ts b/stores/recent-store.ts
new file mode 100644
index 0000000..06b538b
--- /dev/null
+++ b/stores/recent-store.ts
@@ -0,0 +1,69 @@
+import { create } from "zustand";
+import { createMMKV } from "react-native-mmkv";
+
+/**
+ * A record the user has opened, remembered for the Home "Recent" section.
+ * Identified by its route triple (app / object / record) so the same record
+ * opened twice moves to the front instead of duplicating.
+ */
+export interface RecentRecord {
+ /** App route segment the record opens under. */
+ appId: string;
+ /** Object (table) name. */
+ object: string;
+ /** Record id. */
+ recordId: string;
+ /** Display title (the record's resolved name). */
+ title: string;
+ /** Optional secondary line (e.g. the object label). */
+ subtitle?: string;
+ /** Epoch ms of the most recent access — drives ordering. */
+ accessedAt: number;
+}
+
+const storage = createMMKV({ id: "objectstack-recents" });
+const KEY = "records";
+/** Cap the history; the Home section only surfaces the first handful. */
+const MAX_RECENTS = 20;
+
+function keyOf(r: { appId: string; object: string; recordId: string }): string {
+ return `${r.appId}/${r.object}/${r.recordId}`;
+}
+
+function load(): RecentRecord[] {
+ try {
+ const raw = storage.getString(KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? (parsed as RecentRecord[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+interface RecentState {
+ records: RecentRecord[];
+ /** Record (or re-order to front) a viewed record. */
+ track: (record: Omit) => void;
+ /** Forget everything. */
+ clear: () => void;
+}
+
+export const useRecentStore = create((set) => ({
+ records: load(),
+ track: (record) =>
+ set((state) => {
+ const k = keyOf(record);
+ const deduped = state.records.filter((r) => keyOf(r) !== k);
+ const next = [
+ { ...record, accessedAt: Date.now() },
+ ...deduped,
+ ].slice(0, MAX_RECENTS);
+ storage.set(KEY, JSON.stringify(next));
+ return { records: next };
+ }),
+ clear: () => {
+ storage.set(KEY, JSON.stringify([]));
+ set({ records: [] });
+ },
+}));