From 12c35f9a24f6dee089f26731e6aa4b6ab53c1fce Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:24:08 +0000
Subject: [PATCH 01/11] Initial plan
From 6b7f2468aa6742ced5dcd5f404ee8313af6dcc84 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:39:24 +0000
Subject: [PATCH 02/11] feat: implement Phase 14 hooks (useGlobalSearch,
useRecentItems, useFavorites, usePageTransition)
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
__tests__/hooks/useFavorites.test.ts | 97 +++++++++++++++
__tests__/hooks/useGlobalSearch.test.ts | 143 ++++++++++++++++++++++
__tests__/hooks/usePageTransition.test.ts | 83 +++++++++++++
__tests__/hooks/useRecentItems.test.ts | 100 +++++++++++++++
hooks/useFavorites.ts | 91 ++++++++++++++
hooks/useGlobalSearch.ts | 90 ++++++++++++++
hooks/usePageTransition.ts | 96 +++++++++++++++
hooks/useRecentItems.ts | 76 ++++++++++++
8 files changed, 776 insertions(+)
create mode 100644 __tests__/hooks/useFavorites.test.ts
create mode 100644 __tests__/hooks/useGlobalSearch.test.ts
create mode 100644 __tests__/hooks/usePageTransition.test.ts
create mode 100644 __tests__/hooks/useRecentItems.test.ts
create mode 100644 hooks/useFavorites.ts
create mode 100644 hooks/useGlobalSearch.ts
create mode 100644 hooks/usePageTransition.ts
create mode 100644 hooks/useRecentItems.ts
diff --git a/__tests__/hooks/useFavorites.test.ts b/__tests__/hooks/useFavorites.test.ts
new file mode 100644
index 0000000..5314a8b
--- /dev/null
+++ b/__tests__/hooks/useFavorites.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Tests for useFavorites – validates add, remove, toggle,
+ * and isFavorite operations.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useFavorites } from "~/hooks/useFavorites";
+
+describe("useFavorites", () => {
+ it("addFavorite adds an item", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.addFavorite({
+ id: "f-1",
+ type: "dashboard",
+ title: "Sales Dashboard",
+ });
+ });
+
+ expect(result.current.favorites).toHaveLength(1);
+ expect(result.current.favorites[0].id).toBe("f-1");
+ expect(result.current.favorites[0].title).toBe("Sales Dashboard");
+ expect(result.current.favorites[0].pinnedAt).toBeDefined();
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it("addFavorite does not duplicate existing item", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.addFavorite({ id: "f-1", type: "record", title: "Task A" });
+ });
+ act(() => {
+ result.current.addFavorite({ id: "f-1", type: "record", title: "Task A" });
+ });
+
+ expect(result.current.favorites).toHaveLength(1);
+ });
+
+ it("removeFavorite removes an item by id", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.addFavorite({ id: "f-1", type: "record", title: "Task A" });
+ result.current.addFavorite({ id: "f-2", type: "report", title: "Report B" });
+ });
+
+ act(() => {
+ result.current.removeFavorite("f-1");
+ });
+
+ expect(result.current.favorites).toHaveLength(1);
+ expect(result.current.favorites[0].id).toBe("f-2");
+ });
+
+ it("isFavorite returns correct boolean", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.addFavorite({ id: "f-1", type: "dashboard", title: "Sales" });
+ });
+
+ expect(result.current.isFavorite("f-1")).toBe(true);
+ expect(result.current.isFavorite("f-999")).toBe(false);
+ });
+
+ it("toggleFavorite adds when not present", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.toggleFavorite({ id: "f-1", type: "view", title: "My View" });
+ });
+
+ expect(result.current.favorites).toHaveLength(1);
+ expect(result.current.favorites[0].id).toBe("f-1");
+ });
+
+ it("toggleFavorite removes when already present", () => {
+ const { result } = renderHook(() => useFavorites());
+
+ act(() => {
+ result.current.addFavorite({ id: "f-1", type: "view", title: "My View" });
+ });
+ expect(result.current.favorites).toHaveLength(1);
+
+ act(() => {
+ result.current.toggleFavorite({ id: "f-1", type: "view", title: "My View" });
+ });
+
+ expect(result.current.favorites).toHaveLength(0);
+ });
+});
diff --git a/__tests__/hooks/useGlobalSearch.test.ts b/__tests__/hooks/useGlobalSearch.test.ts
new file mode 100644
index 0000000..483aed5
--- /dev/null
+++ b/__tests__/hooks/useGlobalSearch.test.ts
@@ -0,0 +1,143 @@
+/**
+ * Tests for useGlobalSearch – validates search execution,
+ * recent search tracking, and error handling.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+/* ---- Mock useClient from SDK ---- */
+const mockSearch = jest.fn();
+
+const mockClient = {
+ api: {
+ search: mockSearch,
+ },
+};
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => mockClient,
+}));
+
+import { useGlobalSearch } from "~/hooks/useGlobalSearch";
+
+beforeEach(() => {
+ mockSearch.mockReset();
+});
+
+describe("useGlobalSearch", () => {
+ it("search fetches and stores results", async () => {
+ const searchResults = [
+ {
+ id: "sr-1",
+ object: "tasks",
+ recordId: "task-1",
+ title: "Quarterly Report",
+ subtitle: "Q1 2026",
+ score: 0.95,
+ highlight: "Quarterly Report",
+ },
+ {
+ id: "sr-2",
+ object: "documents",
+ recordId: "doc-1",
+ title: "Quarterly Summary",
+ score: 0.8,
+ },
+ ];
+ mockSearch.mockResolvedValue(searchResults);
+
+ const { result } = renderHook(() => useGlobalSearch());
+
+ let returned: unknown;
+ await act(async () => {
+ returned = await result.current.search("quarterly");
+ });
+
+ expect(mockSearch).toHaveBeenCalledWith("quarterly", undefined);
+ expect(returned).toEqual(searchResults);
+ expect(result.current.results).toEqual(searchResults);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("search with options (limit, object)", async () => {
+ const searchResults = [
+ {
+ id: "sr-3",
+ object: "tasks",
+ recordId: "task-2",
+ title: "Budget Report",
+ score: 0.9,
+ },
+ ];
+ mockSearch.mockResolvedValue(searchResults);
+
+ const { result } = renderHook(() => useGlobalSearch());
+
+ let returned: unknown;
+ await act(async () => {
+ returned = await result.current.search("budget", { limit: 5, object: "tasks" });
+ });
+
+ expect(mockSearch).toHaveBeenCalledWith("budget", { limit: 5, object: "tasks" });
+ expect(returned).toEqual(searchResults);
+ expect(result.current.results).toEqual(searchResults);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("tracks recent searches (max 10, no duplicates)", async () => {
+ mockSearch.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useGlobalSearch());
+
+ // Perform multiple searches
+ for (let i = 0; i < 12; i++) {
+ await act(async () => {
+ await result.current.search(`query-${i}`);
+ });
+ }
+
+ expect(result.current.recentSearches).toHaveLength(10);
+ expect(result.current.recentSearches[0]).toBe("query-11");
+ expect(result.current.recentSearches[9]).toBe("query-2");
+
+ // Search a duplicate – it should move to front
+ await act(async () => {
+ await result.current.search("query-5");
+ });
+
+ expect(result.current.recentSearches[0]).toBe("query-5");
+ expect(result.current.recentSearches).toHaveLength(10);
+ });
+
+ it("clearRecent clears all recent searches", async () => {
+ mockSearch.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useGlobalSearch());
+
+ await act(async () => {
+ await result.current.search("test");
+ });
+ expect(result.current.recentSearches).toHaveLength(1);
+
+ act(() => {
+ result.current.clearRecent();
+ });
+
+ expect(result.current.recentSearches).toEqual([]);
+ });
+
+ it("handles search error", async () => {
+ mockSearch.mockRejectedValue(new Error("Search service unavailable"));
+
+ const { result } = renderHook(() => useGlobalSearch());
+
+ await act(async () => {
+ await expect(result.current.search("broken")).rejects.toThrow("Search service unavailable");
+ });
+
+ expect(result.current.results).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error?.message).toBe("Search service unavailable");
+ });
+});
diff --git a/__tests__/hooks/usePageTransition.test.ts b/__tests__/hooks/usePageTransition.test.ts
new file mode 100644
index 0000000..7a3ba28
--- /dev/null
+++ b/__tests__/hooks/usePageTransition.test.ts
@@ -0,0 +1,83 @@
+/**
+ * Tests for usePageTransition – validates transition type setting,
+ * style computation, and reduced-motion handling.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { usePageTransition } from "~/hooks/usePageTransition";
+
+describe("usePageTransition", () => {
+ it("returns default config", () => {
+ const { result } = renderHook(() => usePageTransition());
+
+ expect(result.current.config.type).toBe("slide");
+ expect(result.current.config.duration).toBe(300);
+ expect(result.current.config.damping).toBe(20);
+ expect(result.current.config.stiffness).toBe(200);
+ expect(result.current.isReducedMotion).toBe(false);
+ });
+
+ it("setTransitionType changes config type", () => {
+ const { result } = renderHook(() => usePageTransition());
+
+ act(() => {
+ result.current.setTransitionType("modal");
+ });
+
+ expect(result.current.config.type).toBe("modal");
+ // Other config values should be preserved
+ expect(result.current.config.duration).toBe(300);
+ });
+
+ it("getTransitionStyle returns slide style", () => {
+ const { result } = renderHook(() => usePageTransition());
+
+ const style = result.current.getTransitionStyle(0);
+ expect(style.opacity).toBe(1);
+ expect(style.transform).toBe("translateX(100px)");
+
+ const styleHalf = result.current.getTransitionStyle(0.5);
+ expect(styleHalf.opacity).toBe(1);
+ expect(styleHalf.transform).toBe("translateX(50px)");
+
+ const styleFull = result.current.getTransitionStyle(1);
+ expect(styleFull.opacity).toBe(1);
+ expect(styleFull.transform).toBe("translateX(0px)");
+ });
+
+ it("getTransitionStyle returns modal style", () => {
+ const { result } = renderHook(() => usePageTransition());
+
+ act(() => {
+ result.current.setTransitionType("modal");
+ });
+
+ const style = result.current.getTransitionStyle(0);
+ expect(style.opacity).toBe(0);
+ expect(style.transform).toBe("translateY(50px)");
+
+ const styleFull = result.current.getTransitionStyle(1);
+ expect(styleFull.opacity).toBe(1);
+ expect(styleFull.transform).toBe("translateY(0px)");
+ });
+
+ it("getTransitionStyle returns fade style", () => {
+ const { result } = renderHook(() => usePageTransition());
+
+ act(() => {
+ result.current.setTransitionType("fade");
+ });
+
+ const style = result.current.getTransitionStyle(0);
+ expect(style.opacity).toBe(0);
+ expect(style.transform).toBe("translateX(0px)");
+
+ const styleFull = result.current.getTransitionStyle(1);
+ expect(styleFull.opacity).toBe(1);
+ expect(styleFull.transform).toBe("translateX(0px)");
+ });
+});
diff --git a/__tests__/hooks/useRecentItems.test.ts b/__tests__/hooks/useRecentItems.test.ts
new file mode 100644
index 0000000..b1f065a
--- /dev/null
+++ b/__tests__/hooks/useRecentItems.test.ts
@@ -0,0 +1,100 @@
+/**
+ * Tests for useRecentItems – validates tracking, removal,
+ * clearing, and max-item enforcement.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useRecentItems } from "~/hooks/useRecentItems";
+
+describe("useRecentItems", () => {
+ it("trackAccess adds an item", () => {
+ const { result } = renderHook(() => useRecentItems());
+
+ act(() => {
+ result.current.trackAccess({
+ id: "r-1",
+ object: "tasks",
+ recordId: "task-1",
+ title: "My Task",
+ });
+ });
+
+ expect(result.current.items).toHaveLength(1);
+ expect(result.current.items[0].id).toBe("r-1");
+ expect(result.current.items[0].title).toBe("My Task");
+ expect(result.current.items[0].accessedAt).toBeDefined();
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it("trackAccess moves duplicate to front", () => {
+ const { result } = renderHook(() => useRecentItems());
+
+ act(() => {
+ result.current.trackAccess({ id: "r-1", object: "tasks", recordId: "t-1", title: "First" });
+ });
+ act(() => {
+ result.current.trackAccess({ id: "r-2", object: "tasks", recordId: "t-2", title: "Second" });
+ });
+ act(() => {
+ result.current.trackAccess({ id: "r-1", object: "tasks", recordId: "t-1", title: "First" });
+ });
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0].id).toBe("r-1");
+ expect(result.current.items[1].id).toBe("r-2");
+ });
+
+ it("enforces max 50 items", () => {
+ const { result } = renderHook(() => useRecentItems());
+
+ act(() => {
+ for (let i = 0; i < 55; i++) {
+ result.current.trackAccess({
+ id: `r-${i}`,
+ object: "tasks",
+ recordId: `t-${i}`,
+ title: `Item ${i}`,
+ });
+ }
+ });
+
+ expect(result.current.items).toHaveLength(50);
+ expect(result.current.items[0].id).toBe("r-54");
+ });
+
+ it("removeItem removes a specific item", () => {
+ const { result } = renderHook(() => useRecentItems());
+
+ act(() => {
+ result.current.trackAccess({ id: "r-1", object: "tasks", recordId: "t-1", title: "First" });
+ result.current.trackAccess({ id: "r-2", object: "tasks", recordId: "t-2", title: "Second" });
+ });
+
+ act(() => {
+ result.current.removeItem("r-1");
+ });
+
+ expect(result.current.items).toHaveLength(1);
+ expect(result.current.items[0].id).toBe("r-2");
+ });
+
+ it("clearRecent removes all items", () => {
+ const { result } = renderHook(() => useRecentItems());
+
+ act(() => {
+ result.current.trackAccess({ id: "r-1", object: "tasks", recordId: "t-1", title: "First" });
+ result.current.trackAccess({ id: "r-2", object: "tasks", recordId: "t-2", title: "Second" });
+ });
+ expect(result.current.items).toHaveLength(2);
+
+ act(() => {
+ result.current.clearRecent();
+ });
+
+ expect(result.current.items).toEqual([]);
+ });
+});
diff --git a/hooks/useFavorites.ts b/hooks/useFavorites.ts
new file mode 100644
index 0000000..13bc465
--- /dev/null
+++ b/hooks/useFavorites.ts
@@ -0,0 +1,91 @@
+import { useCallback, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface FavoriteItem {
+ id: string;
+ type: "record" | "dashboard" | "report" | "view";
+ objectName?: string;
+ recordId?: string;
+ title: string;
+ pinnedAt: string;
+}
+
+export interface UseFavoritesResult {
+ /** Current favorite items */
+ favorites: FavoriteItem[];
+ /** Add an item to favorites */
+ addFavorite: (item: Omit) => void;
+ /** Remove a favorite by id */
+ removeFavorite: (id: string) => void;
+ /** Check whether an item is favorited */
+ isFavorite: (id: string) => boolean;
+ /** Toggle favorite state for an item */
+ toggleFavorite: (item: Omit) => void;
+ /** Whether favorites are loading */
+ isLoading: boolean;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for pinning/unpinning records, dashboards, reports, and views.
+ * Pure local state management.
+ *
+ * ```ts
+ * const { favorites, addFavorite, isFavorite, toggleFavorite } = useFavorites();
+ * toggleFavorite({ id: "d-1", type: "dashboard", title: "Sales" });
+ * ```
+ */
+export function useFavorites(): UseFavoritesResult {
+ const [favorites, setFavorites] = useState([]);
+ const [isLoading] = useState(false);
+
+ const addFavorite = useCallback(
+ (item: Omit): void => {
+ setFavorites((prev) => {
+ if (prev.some((f) => f.id === item.id)) return prev;
+ const newItem: FavoriteItem = {
+ ...item,
+ pinnedAt: new Date().toISOString(),
+ };
+ return [...prev, newItem];
+ });
+ },
+ [],
+ );
+
+ const removeFavorite = useCallback((id: string) => {
+ setFavorites((prev) => prev.filter((f) => f.id !== id));
+ }, []);
+
+ const isFavorite = useCallback(
+ (id: string): boolean => {
+ return favorites.some((f) => f.id === id);
+ },
+ [favorites],
+ );
+
+ const toggleFavorite = useCallback(
+ (item: Omit): void => {
+ setFavorites((prev) => {
+ const exists = prev.some((f) => f.id === item.id);
+ if (exists) {
+ return prev.filter((f) => f.id !== item.id);
+ }
+ const newItem: FavoriteItem = {
+ ...item,
+ pinnedAt: new Date().toISOString(),
+ };
+ return [...prev, newItem];
+ });
+ },
+ [],
+ );
+
+ return { favorites, addFavorite, removeFavorite, isFavorite, toggleFavorite, isLoading };
+}
diff --git a/hooks/useGlobalSearch.ts b/hooks/useGlobalSearch.ts
new file mode 100644
index 0000000..e3c7042
--- /dev/null
+++ b/hooks/useGlobalSearch.ts
@@ -0,0 +1,90 @@
+import { useCallback, useState } from "react";
+import { useClient } from "@objectstack/client-react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface SearchResult {
+ id: string;
+ object: string;
+ recordId: string;
+ title: string;
+ subtitle?: string;
+ score: number;
+ highlight?: string;
+}
+
+export interface UseGlobalSearchResult {
+ /** Current search results */
+ results: SearchResult[];
+ /** Execute a search query */
+ search: (
+ query: string,
+ options?: { limit?: number; object?: string },
+ ) => Promise;
+ /** Recently executed search queries */
+ recentSearches: string[];
+ /** Clear recent search history */
+ clearRecent: () => void;
+ /** Whether a search operation is in progress */
+ isLoading: boolean;
+ /** Last error */
+ error: Error | null;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for global search across all objects and records
+ * via `client.api.search()`.
+ *
+ * ```ts
+ * const { results, search, recentSearches } = useGlobalSearch();
+ * await search("quarterly report", { limit: 20 });
+ * ```
+ */
+export function useGlobalSearch(): UseGlobalSearchResult {
+ const client = useClient();
+ const [results, setResults] = useState([]);
+ const [recentSearches, setRecentSearches] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const search = useCallback(
+ async (
+ query: string,
+ options?: { limit?: number; object?: string },
+ ): Promise => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = await (client as any).api?.search(query, options);
+ const items: SearchResult[] = result ?? [];
+ setResults(items);
+ setRecentSearches((prev) => {
+ const filtered = prev.filter((s) => s !== query);
+ return [query, ...filtered].slice(0, 10);
+ });
+ return items;
+ } catch (err: unknown) {
+ const searchError =
+ err instanceof Error ? err : new Error("Failed to execute search");
+ setError(searchError);
+ throw searchError;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [client],
+ );
+
+ const clearRecent = useCallback(() => {
+ setRecentSearches([]);
+ }, []);
+
+ return { results, search, recentSearches, clearRecent, isLoading, error };
+}
diff --git a/hooks/usePageTransition.ts b/hooks/usePageTransition.ts
new file mode 100644
index 0000000..9056065
--- /dev/null
+++ b/hooks/usePageTransition.ts
@@ -0,0 +1,96 @@
+import { useCallback, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface TransitionConfig {
+ type: "slide" | "modal" | "fade";
+ duration?: number;
+ damping?: number;
+ stiffness?: number;
+}
+
+export interface TransitionStyle {
+ opacity: number;
+ transform: string;
+}
+
+export interface UsePageTransitionResult {
+ /** Current transition configuration */
+ config: TransitionConfig;
+ /** Set the transition type */
+ setTransitionType: (type: TransitionConfig["type"]) => void;
+ /** Get computed transition style for a given progress value (0–1) */
+ getTransitionStyle: (progress: number) => TransitionStyle;
+ /** Whether reduced-motion is preferred */
+ isReducedMotion: boolean;
+}
+
+/* ------------------------------------------------------------------ */
+/* Defaults */
+/* ------------------------------------------------------------------ */
+
+const DEFAULT_CONFIG: TransitionConfig = {
+ type: "slide",
+ duration: 300,
+ damping: 20,
+ stiffness: 200,
+};
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for spring-based page transition configuration.
+ * Pure local state – no server calls.
+ *
+ * ```ts
+ * const { config, setTransitionType, getTransitionStyle } = usePageTransition();
+ * setTransitionType("modal");
+ * const style = getTransitionStyle(0.5);
+ * ```
+ */
+export function usePageTransition(): UsePageTransitionResult {
+ const [config, setConfig] = useState(DEFAULT_CONFIG);
+ const [isReducedMotion] = useState(false);
+
+ const setTransitionType = useCallback(
+ (type: TransitionConfig["type"]): void => {
+ setConfig((prev) => ({ ...prev, type }));
+ },
+ [],
+ );
+
+ const getTransitionStyle = useCallback(
+ (progress: number): TransitionStyle => {
+ if (isReducedMotion) {
+ return { opacity: progress, transform: "translateX(0px)" };
+ }
+
+ switch (config.type) {
+ case "slide":
+ return {
+ opacity: 1,
+ transform: `translateX(${(1 - progress) * 100}px)`,
+ };
+ case "modal":
+ return {
+ opacity: progress,
+ transform: `translateY(${(1 - progress) * 50}px)`,
+ };
+ case "fade":
+ return {
+ opacity: progress,
+ transform: "translateX(0px)",
+ };
+ default:
+ return { opacity: progress, transform: "translateX(0px)" };
+ }
+ },
+ [config.type, isReducedMotion],
+ );
+
+ return { config, setTransitionType, getTransitionStyle, isReducedMotion };
+}
diff --git a/hooks/useRecentItems.ts b/hooks/useRecentItems.ts
new file mode 100644
index 0000000..6dee101
--- /dev/null
+++ b/hooks/useRecentItems.ts
@@ -0,0 +1,76 @@
+import { useCallback, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface RecentItem {
+ id: string;
+ object: string;
+ recordId: string;
+ title: string;
+ subtitle?: string;
+ accessedAt: string;
+ icon?: string;
+}
+
+export interface UseRecentItemsResult {
+ /** Recently accessed items */
+ items: RecentItem[];
+ /** Track access to an item */
+ trackAccess: (item: Omit) => void;
+ /** Clear all recent items */
+ clearRecent: () => void;
+ /** Remove a specific item by id */
+ removeItem: (id: string) => void;
+ /** Whether recent items are loading */
+ isLoading: boolean;
+}
+
+/* ------------------------------------------------------------------ */
+/* Constants */
+/* ------------------------------------------------------------------ */
+
+const MAX_RECENT_ITEMS = 50;
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for tracking recently accessed records (last 50).
+ * Pure local state – no server calls needed.
+ *
+ * ```ts
+ * const { items, trackAccess, clearRecent } = useRecentItems();
+ * trackAccess({ id: "r-1", object: "tasks", recordId: "t-1", title: "My Task" });
+ * ```
+ */
+export function useRecentItems(): UseRecentItemsResult {
+ const [items, setItems] = useState([]);
+ const [isLoading] = useState(false);
+
+ const trackAccess = useCallback(
+ (item: Omit): void => {
+ setItems((prev) => {
+ const filtered = prev.filter((i) => i.id !== item.id);
+ const newItem: RecentItem = {
+ ...item,
+ accessedAt: new Date().toISOString(),
+ };
+ return [newItem, ...filtered].slice(0, MAX_RECENT_ITEMS);
+ });
+ },
+ [],
+ );
+
+ const clearRecent = useCallback(() => {
+ setItems([]);
+ }, []);
+
+ const removeItem = useCallback((id: string) => {
+ setItems((prev) => prev.filter((i) => i.id !== id));
+ }, []);
+
+ return { items, trackAccess, clearRecent, removeItem, isLoading };
+}
From 2c7afb86b3635af41f3e8273def116e69719ad85 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:43:19 +0000
Subject: [PATCH 03/11] feat: implement Phase 14 skeleton components,
FloatingActionButton, and UndoSnackbar
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
.../components/FloatingActionButton.test.tsx | 65 +++++++++++++++++
.../components/SkeletonDashboard.test.tsx | 31 ++++++++
__tests__/components/SkeletonDetail.test.tsx | 37 ++++++++++
__tests__/components/SkeletonForm.test.tsx | 38 ++++++++++
__tests__/components/SkeletonList.test.tsx | 41 +++++++++++
__tests__/components/UndoSnackbar.test.tsx | 73 +++++++++++++++++++
components/common/FloatingActionButton.tsx | 63 ++++++++++++++++
components/common/SkeletonDashboard.tsx | 28 +++++++
components/common/SkeletonDetail.tsx | 37 ++++++++++
components/common/SkeletonForm.tsx | 25 +++++++
components/common/SkeletonList.tsx | 26 +++++++
components/common/UndoSnackbar.tsx | 61 ++++++++++++++++
12 files changed, 525 insertions(+)
create mode 100644 __tests__/components/FloatingActionButton.test.tsx
create mode 100644 __tests__/components/SkeletonDashboard.test.tsx
create mode 100644 __tests__/components/SkeletonDetail.test.tsx
create mode 100644 __tests__/components/SkeletonForm.test.tsx
create mode 100644 __tests__/components/SkeletonList.test.tsx
create mode 100644 __tests__/components/UndoSnackbar.test.tsx
create mode 100644 components/common/FloatingActionButton.tsx
create mode 100644 components/common/SkeletonDashboard.tsx
create mode 100644 components/common/SkeletonDetail.tsx
create mode 100644 components/common/SkeletonForm.tsx
create mode 100644 components/common/SkeletonList.tsx
create mode 100644 components/common/UndoSnackbar.tsx
diff --git a/__tests__/components/FloatingActionButton.test.tsx b/__tests__/components/FloatingActionButton.test.tsx
new file mode 100644
index 0000000..d189b08
--- /dev/null
+++ b/__tests__/components/FloatingActionButton.test.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { render, fireEvent } from "@testing-library/react-native";
+
+import { FloatingActionButton } from "~/components/common/FloatingActionButton";
+import type { FABAction } from "~/components/common/FloatingActionButton";
+
+describe("FloatingActionButton", () => {
+ it("renders with default props", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("fab")).toBeTruthy();
+ expect(getByTestId("fab-button")).toBeTruthy();
+ });
+
+ it("calls onPress in simple mode", () => {
+ const onPress = jest.fn();
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId("fab-button"));
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it("expands to show actions when pressed with actions", () => {
+ const actions: FABAction[] = [
+ { id: "a1", label: "Action 1", onPress: jest.fn() },
+ { id: "a2", label: "Action 2", onPress: jest.fn() },
+ ];
+ const { getByTestId, queryByTestId } = render(
+
+ );
+
+ // Actions not visible initially
+ expect(queryByTestId("fab-action-a1")).toBeNull();
+
+ // Press to expand
+ fireEvent.press(getByTestId("fab-button"));
+ expect(getByTestId("fab-action-a1")).toBeTruthy();
+ expect(getByTestId("fab-action-a2")).toBeTruthy();
+ });
+
+ it("calls action onPress and collapses", () => {
+ const actionFn = jest.fn();
+ const actions: FABAction[] = [
+ { id: "a1", label: "Action 1", onPress: actionFn },
+ ];
+ const { getByTestId, queryByTestId } = render(
+
+ );
+
+ fireEvent.press(getByTestId("fab-button"));
+ fireEvent.press(getByTestId("fab-action-a1"));
+
+ expect(actionFn).toHaveBeenCalledTimes(1);
+ // Should collapse after action press
+ expect(queryByTestId("fab-action-a1")).toBeNull();
+ });
+
+ it("has correct accessibility labels", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("fab-button").props.accessibilityRole).toBe("button");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("my-fab")).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/SkeletonDashboard.test.tsx b/__tests__/components/SkeletonDashboard.test.tsx
new file mode 100644
index 0000000..5c9c05a
--- /dev/null
+++ b/__tests__/components/SkeletonDashboard.test.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { SkeletonDashboard } from "~/components/common/SkeletonDashboard";
+
+describe("SkeletonDashboard", () => {
+ it("renders with default props", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("skeleton-dashboard")).toBeTruthy();
+ });
+
+ it("renders the correct number of cards", () => {
+ const { getByTestId, queryByTestId } = render();
+ expect(getByTestId("skeleton-dashboard-card-0")).toBeTruthy();
+ expect(getByTestId("skeleton-dashboard-card-1")).toBeTruthy();
+ expect(getByTestId("skeleton-dashboard-card-2")).toBeTruthy();
+ expect(queryByTestId("skeleton-dashboard-card-3")).toBeNull();
+ });
+
+ it("has correct accessibility attributes", () => {
+ const { getByTestId } = render();
+ const root = getByTestId("skeleton-dashboard");
+ expect(root.props.accessibilityLabel).toBe("Loading dashboard");
+ expect(root.props.accessibilityRole).toBe("progressbar");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("my-dash")).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/SkeletonDetail.test.tsx b/__tests__/components/SkeletonDetail.test.tsx
new file mode 100644
index 0000000..8da3bfc
--- /dev/null
+++ b/__tests__/components/SkeletonDetail.test.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { SkeletonDetail } from "~/components/common/SkeletonDetail";
+
+describe("SkeletonDetail", () => {
+ it("renders with default props", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("skeleton-detail")).toBeTruthy();
+ });
+
+ it("renders the correct number of sections", () => {
+ const { getByTestId, queryByTestId } = render();
+ expect(getByTestId("skeleton-detail-section-0")).toBeTruthy();
+ expect(getByTestId("skeleton-detail-section-1")).toBeTruthy();
+ expect(queryByTestId("skeleton-detail-section-2")).toBeNull();
+ });
+
+ it("has correct accessibility attributes", () => {
+ const { getByTestId } = render();
+ const root = getByTestId("skeleton-detail");
+ expect(root.props.accessibilityLabel).toBe("Loading detail");
+ expect(root.props.accessibilityRole).toBe("progressbar");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("my-detail")).toBeTruthy();
+ });
+
+ it("renders fields per section correctly", () => {
+ const { getByTestId } = render();
+ const section = getByTestId("skeleton-detail-section-0");
+ // Section title + 2 fields = 3 children
+ expect(section.children.length).toBe(3);
+ });
+});
diff --git a/__tests__/components/SkeletonForm.test.tsx b/__tests__/components/SkeletonForm.test.tsx
new file mode 100644
index 0000000..a88a8a7
--- /dev/null
+++ b/__tests__/components/SkeletonForm.test.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { SkeletonForm } from "~/components/common/SkeletonForm";
+
+describe("SkeletonForm", () => {
+ it("renders with default props", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("skeleton-form")).toBeTruthy();
+ });
+
+ it("renders the correct number of fields", () => {
+ const { getByTestId, queryByTestId } = render();
+ expect(getByTestId("skeleton-form-field-0")).toBeTruthy();
+ expect(getByTestId("skeleton-form-field-1")).toBeTruthy();
+ expect(getByTestId("skeleton-form-field-2")).toBeTruthy();
+ expect(queryByTestId("skeleton-form-field-3")).toBeNull();
+ });
+
+ it("has correct accessibility attributes", () => {
+ const { getByTestId } = render();
+ const root = getByTestId("skeleton-form");
+ expect(root.props.accessibilityLabel).toBe("Loading form");
+ expect(root.props.accessibilityRole).toBe("progressbar");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("my-form")).toBeTruthy();
+ });
+
+ it("each field has label and input skeletons", () => {
+ const { getByTestId } = render();
+ const field = getByTestId("skeleton-form-field-0");
+ // Label + input = 2 children
+ expect(field.children.length).toBe(2);
+ });
+});
diff --git a/__tests__/components/SkeletonList.test.tsx b/__tests__/components/SkeletonList.test.tsx
new file mode 100644
index 0000000..a739cdd
--- /dev/null
+++ b/__tests__/components/SkeletonList.test.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { SkeletonList } from "~/components/common/SkeletonList";
+
+describe("SkeletonList", () => {
+ it("renders with default props", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("skeleton-list")).toBeTruthy();
+ });
+
+ it("renders the correct number of rows", () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ // Root has 3 row children
+ expect(tree.children).toHaveLength(3);
+ });
+
+ it("hides avatars when showAvatar is false", () => {
+ const { toJSON: withAvatar } = render();
+ const { toJSON: withoutAvatar } = render();
+ const withAvatarTree = withAvatar();
+ const withoutAvatarTree = withoutAvatar();
+ // Row with avatar has more children than without
+ expect(withAvatarTree.children[0].children.length).toBeGreaterThan(
+ withoutAvatarTree.children[0].children.length
+ );
+ });
+
+ it("has correct accessibility attributes", () => {
+ const { getByTestId } = render();
+ const root = getByTestId("skeleton-list");
+ expect(root.props.accessibilityLabel).toBe("Loading list");
+ expect(root.props.accessibilityRole).toBe("progressbar");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render();
+ expect(getByTestId("custom-skeleton")).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/UndoSnackbar.test.tsx b/__tests__/components/UndoSnackbar.test.tsx
new file mode 100644
index 0000000..5fdcd9f
--- /dev/null
+++ b/__tests__/components/UndoSnackbar.test.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import { render, fireEvent, act } from "@testing-library/react-native";
+
+import { UndoSnackbar } from "~/components/common/UndoSnackbar";
+
+describe("UndoSnackbar", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("renders when visible", () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId("undo-snackbar")).toBeTruthy();
+ });
+
+ it("does not render when not visible", () => {
+ const { queryByTestId } = render(
+
+ );
+ expect(queryByTestId("undo-snackbar")).toBeNull();
+ });
+
+ it("displays the message text", () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId("undo-snackbar-message").props.children).toBe("Record removed");
+ });
+
+ it("calls onUndo when undo is pressed", () => {
+ const onUndo = jest.fn();
+ const { getByTestId } = render(
+
+ );
+ fireEvent.press(getByTestId("undo-snackbar-undo"));
+ expect(onUndo).toHaveBeenCalledTimes(1);
+ });
+
+ it("auto-hides after duration", () => {
+ const { queryByTestId } = render(
+
+ );
+ expect(queryByTestId("undo-snackbar")).toBeTruthy();
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(queryByTestId("undo-snackbar")).toBeNull();
+ });
+
+ it("has correct accessibility attributes", () => {
+ const { getByTestId } = render(
+
+ );
+ const root = getByTestId("undo-snackbar");
+ expect(root.props.accessibilityRole).toBe("alert");
+ expect(root.props.accessibilityLabel).toBe("Deleted");
+ });
+
+ it("uses custom testID", () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId("my-snackbar")).toBeTruthy();
+ });
+});
diff --git a/components/common/FloatingActionButton.tsx b/components/common/FloatingActionButton.tsx
new file mode 100644
index 0000000..300f7ed
--- /dev/null
+++ b/components/common/FloatingActionButton.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import { TouchableOpacity, View, Text } from "react-native";
+
+export interface FABAction {
+ id: string;
+ label: string;
+ icon?: string;
+ onPress: () => void;
+}
+
+export interface FloatingActionButtonProps {
+ actions?: FABAction[];
+ onPress?: () => void;
+ testID?: string;
+}
+
+export function FloatingActionButton({
+ actions,
+ onPress,
+ testID = "fab",
+}: FloatingActionButtonProps) {
+ const [expanded, setExpanded] = React.useState(false);
+
+ const handlePress = () => {
+ if (actions && actions.length > 0) {
+ setExpanded((prev) => !prev);
+ } else if (onPress) {
+ onPress();
+ }
+ };
+
+ return (
+
+ {/* Expanded action items */}
+ {expanded && actions && actions.map((action) => (
+ {
+ 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"
+ >
+ {action.label}
+
+ ))}
+
+ {/* Primary FAB button */}
+
+ {expanded ? "×" : "+"}
+
+
+ );
+}
diff --git a/components/common/SkeletonDashboard.tsx b/components/common/SkeletonDashboard.tsx
new file mode 100644
index 0000000..d681061
--- /dev/null
+++ b/components/common/SkeletonDashboard.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { View } from "react-native";
+
+export interface SkeletonDashboardProps {
+ cards?: number;
+ testID?: string;
+}
+
+export function SkeletonDashboard({
+ cards = 4,
+ testID = "skeleton-dashboard",
+}: SkeletonDashboardProps) {
+ return (
+
+
+ {Array.from({ length: cards }).map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/common/SkeletonDetail.tsx b/components/common/SkeletonDetail.tsx
new file mode 100644
index 0000000..60592db
--- /dev/null
+++ b/components/common/SkeletonDetail.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { View } from "react-native";
+
+export interface SkeletonDetailProps {
+ sections?: number;
+ fieldsPerSection?: number;
+ testID?: string;
+}
+
+export function SkeletonDetail({
+ sections = 3,
+ fieldsPerSection = 4,
+ testID = "skeleton-detail",
+}: SkeletonDetailProps) {
+ return (
+
+ {/* Header skeleton */}
+
+
+
+
+
+ {/* Sections */}
+ {Array.from({ length: sections }).map((_, s) => (
+
+
+ {Array.from({ length: fieldsPerSection }).map((_, f) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/components/common/SkeletonForm.tsx b/components/common/SkeletonForm.tsx
new file mode 100644
index 0000000..fbca630
--- /dev/null
+++ b/components/common/SkeletonForm.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { View } from "react-native";
+
+export interface SkeletonFormProps {
+ fields?: number;
+ testID?: string;
+}
+
+export function SkeletonForm({
+ fields = 5,
+ testID = "skeleton-form",
+}: SkeletonFormProps) {
+ return (
+
+ {Array.from({ length: fields }).map((_, i) => (
+
+ {/* Label skeleton */}
+
+ {/* Input skeleton */}
+
+
+ ))}
+
+ );
+}
diff --git a/components/common/SkeletonList.tsx b/components/common/SkeletonList.tsx
new file mode 100644
index 0000000..412df8a
--- /dev/null
+++ b/components/common/SkeletonList.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { View } from "react-native";
+
+export interface SkeletonListProps {
+ rows?: number;
+ showAvatar?: boolean;
+ testID?: string;
+}
+
+export function SkeletonList({ rows = 5, showAvatar = true, testID = "skeleton-list" }: SkeletonListProps) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ {showAvatar && (
+
+ )}
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/common/UndoSnackbar.tsx b/components/common/UndoSnackbar.tsx
new file mode 100644
index 0000000..0d2f982
--- /dev/null
+++ b/components/common/UndoSnackbar.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+
+export interface UndoSnackbarProps {
+ message: string;
+ onUndo: () => void;
+ duration?: number;
+ visible: boolean;
+ testID?: string;
+}
+
+export function UndoSnackbar({
+ message,
+ onUndo,
+ duration = 5000,
+ visible,
+ testID = "undo-snackbar",
+}: UndoSnackbarProps) {
+ const timerRef = React.useRef | null>(null);
+ const [show, setShow] = React.useState(visible);
+
+ React.useEffect(() => {
+ if (visible) {
+ setShow(true);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ setShow(false);
+ }, duration);
+ } else {
+ setShow(false);
+ if (timerRef.current) clearTimeout(timerRef.current);
+ }
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, [visible, duration]);
+
+ if (!show) return null;
+
+ return (
+
+
+ {message}
+
+
+ Undo
+
+
+ );
+}
From 8c580dada8601635822a3a07328deb62165ed9de Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:46:59 +0000
Subject: [PATCH 04/11] feat: implement Phase 15 hooks (useInlineEdit,
useContextualActions, useUndoRedo, useQuickActions) with tests
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
__tests__/hooks/useContextualActions.test.ts | 227 +++++++++++++++++++
__tests__/hooks/useInlineEdit.test.ts | 135 +++++++++++
__tests__/hooks/useQuickActions.test.ts | 144 ++++++++++++
__tests__/hooks/useUndoRedo.test.ts | 154 +++++++++++++
hooks/useContextualActions.ts | 118 ++++++++++
hooks/useInlineEdit.ts | 121 ++++++++++
hooks/useQuickActions.ts | 100 ++++++++
hooks/useUndoRedo.ts | 104 +++++++++
8 files changed, 1103 insertions(+)
create mode 100644 __tests__/hooks/useContextualActions.test.ts
create mode 100644 __tests__/hooks/useInlineEdit.test.ts
create mode 100644 __tests__/hooks/useQuickActions.test.ts
create mode 100644 __tests__/hooks/useUndoRedo.test.ts
create mode 100644 hooks/useContextualActions.ts
create mode 100644 hooks/useInlineEdit.ts
create mode 100644 hooks/useQuickActions.ts
create mode 100644 hooks/useUndoRedo.ts
diff --git a/__tests__/hooks/useContextualActions.test.ts b/__tests__/hooks/useContextualActions.test.ts
new file mode 100644
index 0000000..fd0c823
--- /dev/null
+++ b/__tests__/hooks/useContextualActions.test.ts
@@ -0,0 +1,227 @@
+/**
+ * Tests for useContextualActions – validates detection of actionable
+ * fields and URL scheme execution.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+/* ---- Mock expo-linking ---- */
+jest.mock("expo-linking", () => ({ openURL: jest.fn() }));
+
+/* ---- Mock useClient from SDK (not used but required by module system) ---- */
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import * as Linking from "expo-linking";
+import { useContextualActions } from "~/hooks/useContextualActions";
+
+beforeEach(() => {
+ (Linking.openURL as jest.Mock).mockReset();
+});
+
+describe("useContextualActions", () => {
+ it("starts with empty actions", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ expect(result.current.actions).toEqual([]);
+ });
+
+ it("detectActions identifies phone fields", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "mobile", type: "phone", value: "+1234567890" },
+ ]);
+ });
+
+ expect(detected).toEqual([
+ {
+ type: "phone",
+ label: "Call mobile",
+ value: "+1234567890",
+ field: "mobile",
+ },
+ ]);
+ expect(result.current.actions).toEqual(detected);
+ });
+
+ it("detectActions identifies email fields", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "email", type: "email", value: "user@example.com" },
+ ]);
+ });
+
+ expect(detected).toEqual([
+ {
+ type: "email",
+ label: "Email email",
+ value: "user@example.com",
+ field: "email",
+ },
+ ]);
+ });
+
+ it("detectActions identifies url fields", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "website", type: "url", value: "https://example.com" },
+ ]);
+ });
+
+ expect(detected).toEqual([
+ {
+ type: "url",
+ label: "Open website",
+ value: "https://example.com",
+ field: "website",
+ },
+ ]);
+ });
+
+ it("detectActions identifies address fields", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "location", type: "address", value: "123 Main St" },
+ ]);
+ });
+
+ expect(detected).toEqual([
+ {
+ type: "address",
+ label: "Map location",
+ value: "123 Main St",
+ field: "location",
+ },
+ ]);
+ });
+
+ it("detectActions skips empty and null values", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "phone", type: "phone", value: null },
+ { name: "email", type: "email", value: "" },
+ { name: "notes", type: "text", value: "Some note" },
+ ]);
+ });
+
+ expect(detected).toEqual([]);
+ });
+
+ it("detectActions handles multiple fields", () => {
+ const { result } = renderHook(() => useContextualActions());
+
+ let detected: unknown;
+ act(() => {
+ detected = result.current.detectActions([
+ { name: "phone", type: "phone", value: "+1234567890" },
+ { name: "email", type: "email", value: "test@test.com" },
+ { name: "website", type: "url", value: "https://test.com" },
+ { name: "office", type: "address", value: "456 Oak Ave" },
+ ]);
+ });
+
+ expect((detected as unknown[]).length).toBe(4);
+ });
+
+ it("executeAction opens tel: URL for phone", async () => {
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useContextualActions());
+
+ await act(async () => {
+ await result.current.executeAction({
+ type: "phone",
+ label: "Call mobile",
+ value: "+1234567890",
+ field: "mobile",
+ });
+ });
+
+ expect(Linking.openURL).toHaveBeenCalledWith("tel:+1234567890");
+ });
+
+ it("executeAction opens mailto: URL for email", async () => {
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useContextualActions());
+
+ await act(async () => {
+ await result.current.executeAction({
+ type: "email",
+ label: "Email email",
+ value: "user@example.com",
+ field: "email",
+ });
+ });
+
+ expect(Linking.openURL).toHaveBeenCalledWith("mailto:user@example.com");
+ });
+
+ it("executeAction opens https: URL for url", async () => {
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useContextualActions());
+
+ await act(async () => {
+ await result.current.executeAction({
+ type: "url",
+ label: "Open website",
+ value: "https://example.com",
+ field: "website",
+ });
+ });
+
+ expect(Linking.openURL).toHaveBeenCalledWith("https://example.com");
+ });
+
+ it("executeAction prepends https for url without protocol", async () => {
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useContextualActions());
+
+ await act(async () => {
+ await result.current.executeAction({
+ type: "url",
+ label: "Open website",
+ value: "example.com",
+ field: "website",
+ });
+ });
+
+ expect(Linking.openURL).toHaveBeenCalledWith("https://example.com");
+ });
+
+ it("executeAction opens maps: URL for address", async () => {
+ (Linking.openURL as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useContextualActions());
+
+ await act(async () => {
+ await result.current.executeAction({
+ type: "address",
+ label: "Map location",
+ value: "123 Main St",
+ field: "location",
+ });
+ });
+
+ expect(Linking.openURL).toHaveBeenCalledWith(
+ `maps:?q=${encodeURIComponent("123 Main St")}`,
+ );
+ });
+});
diff --git a/__tests__/hooks/useInlineEdit.test.ts b/__tests__/hooks/useInlineEdit.test.ts
new file mode 100644
index 0000000..af4d284
--- /dev/null
+++ b/__tests__/hooks/useInlineEdit.test.ts
@@ -0,0 +1,135 @@
+/**
+ * Tests for useInlineEdit – validates inline field editing
+ * with save, cancel, and dirty state tracking.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+/* ---- Mock useClient from SDK ---- */
+const mockUpdate = jest.fn();
+
+const mockClient = {
+ api: { update: mockUpdate },
+};
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => mockClient,
+}));
+
+import { useInlineEdit } from "~/hooks/useInlineEdit";
+
+beforeEach(() => {
+ mockUpdate.mockReset();
+});
+
+describe("useInlineEdit", () => {
+ it("starts in idle state", () => {
+ const { result } = renderHook(() => useInlineEdit());
+
+ expect(result.current.editingField).toBeNull();
+ expect(result.current.currentValue).toBeUndefined();
+ expect(result.current.isDirty).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("startEdit sets field and value", () => {
+ const { result } = renderHook(() => useInlineEdit());
+
+ act(() => {
+ result.current.startEdit("tasks", "task-1", "title", "Old title");
+ });
+
+ expect(result.current.editingField).toBe("title");
+ expect(result.current.currentValue).toBe("Old title");
+ expect(result.current.isDirty).toBe(false);
+ });
+
+ it("updateValue marks field as dirty", () => {
+ const { result } = renderHook(() => useInlineEdit());
+
+ act(() => {
+ result.current.startEdit("tasks", "task-1", "title", "Old title");
+ });
+ act(() => {
+ result.current.updateValue("New title");
+ });
+
+ expect(result.current.currentValue).toBe("New title");
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it("cancelEdit resets to original value", () => {
+ const { result } = renderHook(() => useInlineEdit());
+
+ act(() => {
+ result.current.startEdit("tasks", "task-1", "title", "Old title");
+ });
+ act(() => {
+ result.current.updateValue("New title");
+ });
+ act(() => {
+ result.current.cancelEdit();
+ });
+
+ expect(result.current.editingField).toBeNull();
+ expect(result.current.currentValue).toBe("Old title");
+ expect(result.current.isDirty).toBe(false);
+ });
+
+ it("saveEdit persists value and clears editing state", async () => {
+ mockUpdate.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useInlineEdit());
+
+ act(() => {
+ result.current.startEdit("tasks", "task-1", "title", "Old title");
+ });
+ act(() => {
+ result.current.updateValue("New title");
+ });
+
+ await act(async () => {
+ await result.current.saveEdit();
+ });
+
+ expect(mockUpdate).toHaveBeenCalledWith("tasks", "task-1", {
+ title: "New title",
+ });
+ expect(result.current.editingField).toBeNull();
+ expect(result.current.isDirty).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("handles saveEdit error", async () => {
+ mockUpdate.mockRejectedValue(new Error("Update failed"));
+
+ const { result } = renderHook(() => useInlineEdit());
+
+ act(() => {
+ result.current.startEdit("tasks", "task-1", "title", "Old title");
+ });
+ act(() => {
+ result.current.updateValue("New title");
+ });
+
+ await act(async () => {
+ await expect(result.current.saveEdit()).rejects.toThrow("Update failed");
+ });
+
+ expect(result.current.editingField).toBe("title");
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error?.message).toBe("Update failed");
+ });
+
+ it("saveEdit is a no-op when not editing", async () => {
+ const { result } = renderHook(() => useInlineEdit());
+
+ await act(async () => {
+ await result.current.saveEdit();
+ });
+
+ expect(mockUpdate).not.toHaveBeenCalled();
+ expect(result.current.isLoading).toBe(false);
+ });
+});
diff --git a/__tests__/hooks/useQuickActions.test.ts b/__tests__/hooks/useQuickActions.test.ts
new file mode 100644
index 0000000..58e0e92
--- /dev/null
+++ b/__tests__/hooks/useQuickActions.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Tests for useQuickActions – validates default actions,
+ * registration, removal, and execution.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+/* ---- Mock useClient from SDK (not used but required by module system) ---- */
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useQuickActions } from "~/hooks/useQuickActions";
+
+describe("useQuickActions", () => {
+ it("starts with default actions", () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ expect(result.current.actions).toHaveLength(3);
+ expect(result.current.actions.map((a) => a.id)).toEqual([
+ "new-record",
+ "search",
+ "scan",
+ ]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("registerAction adds a new action", () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ act(() => {
+ result.current.registerAction({
+ id: "import",
+ label: "Import",
+ icon: "upload",
+ type: "custom",
+ });
+ });
+
+ expect(result.current.actions).toHaveLength(4);
+ expect(result.current.actions[3].id).toBe("import");
+ });
+
+ it("registerAction updates existing action with same id", () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ act(() => {
+ result.current.registerAction({
+ id: "search",
+ label: "Global Search",
+ icon: "magnify",
+ type: "search",
+ });
+ });
+
+ expect(result.current.actions).toHaveLength(3);
+ const searchAction = result.current.actions.find((a) => a.id === "search");
+ expect(searchAction?.label).toBe("Global Search");
+ expect(searchAction?.icon).toBe("magnify");
+ });
+
+ it("removeAction removes by id", () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ act(() => {
+ result.current.removeAction("scan");
+ });
+
+ expect(result.current.actions).toHaveLength(2);
+ expect(result.current.actions.find((a) => a.id === "scan")).toBeUndefined();
+ });
+
+ it("executeAction calls onExecute for matching action", async () => {
+ const onExecute = jest.fn();
+ const { result } = renderHook(() => useQuickActions());
+
+ act(() => {
+ result.current.registerAction({
+ id: "custom",
+ label: "Custom",
+ icon: "star",
+ type: "custom",
+ onExecute,
+ });
+ });
+
+ await act(async () => {
+ await result.current.executeAction("custom");
+ });
+
+ expect(onExecute).toHaveBeenCalledTimes(1);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("executeAction throws for unknown action id", async () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ await act(async () => {
+ await expect(
+ result.current.executeAction("nonexistent"),
+ ).rejects.toThrow("Action not found: nonexistent");
+ });
+
+ expect(result.current.error?.message).toBe(
+ "Action not found: nonexistent",
+ );
+ });
+
+ it("executeAction handles action without onExecute", async () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ await act(async () => {
+ await result.current.executeAction("new-record");
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("executeAction handles onExecute error", async () => {
+ const { result } = renderHook(() => useQuickActions());
+
+ act(() => {
+ result.current.registerAction({
+ id: "failing",
+ label: "Failing",
+ icon: "alert",
+ type: "custom",
+ onExecute: () => {
+ throw new Error("Execution failed");
+ },
+ });
+ });
+
+ await act(async () => {
+ await expect(
+ result.current.executeAction("failing"),
+ ).rejects.toThrow("Execution failed");
+ });
+
+ expect(result.current.error?.message).toBe("Execution failed");
+ });
+});
diff --git a/__tests__/hooks/useUndoRedo.test.ts b/__tests__/hooks/useUndoRedo.test.ts
new file mode 100644
index 0000000..d7e61b8
--- /dev/null
+++ b/__tests__/hooks/useUndoRedo.test.ts
@@ -0,0 +1,154 @@
+/**
+ * Tests for useUndoRedo – validates undo stack management,
+ * undo execution, and dismiss behavior.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+/* ---- Mock useClient from SDK (not used but required by module system) ---- */
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useUndoRedo } from "~/hooks/useUndoRedo";
+
+describe("useUndoRedo", () => {
+ it("starts with empty stack", () => {
+ const { result } = renderHook(() => useUndoRedo());
+
+ expect(result.current.canUndo).toBe(false);
+ expect(result.current.lastAction).toBeNull();
+ expect(result.current.isUndoing).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("pushAction adds action to stack", () => {
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({
+ description: "Deleted record",
+ undo: jest.fn(),
+ });
+ });
+
+ expect(result.current.canUndo).toBe(true);
+ expect(result.current.lastAction).not.toBeNull();
+ expect(result.current.lastAction?.description).toBe("Deleted record");
+ expect(result.current.lastAction?.id).toMatch(/^undo-/);
+ expect(typeof result.current.lastAction?.timestamp).toBe("number");
+ });
+
+ it("pushAction stacks multiple actions", () => {
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({
+ description: "Action 1",
+ undo: jest.fn(),
+ });
+ });
+ act(() => {
+ result.current.pushAction({
+ description: "Action 2",
+ undo: jest.fn(),
+ });
+ });
+
+ expect(result.current.canUndo).toBe(true);
+ expect(result.current.lastAction?.description).toBe("Action 2");
+ });
+
+ it("undo calls the last action's undo function", async () => {
+ const undoFn = jest.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({
+ description: "Deleted record",
+ undo: undoFn,
+ });
+ });
+
+ await act(async () => {
+ await result.current.undo();
+ });
+
+ expect(undoFn).toHaveBeenCalledTimes(1);
+ expect(result.current.canUndo).toBe(false);
+ expect(result.current.lastAction).toBeNull();
+ expect(result.current.isUndoing).toBe(false);
+ });
+
+ it("undo removes only the last action", async () => {
+ const undoFn1 = jest.fn().mockResolvedValue(undefined);
+ const undoFn2 = jest.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({ description: "Action 1", undo: undoFn1 });
+ });
+ act(() => {
+ result.current.pushAction({ description: "Action 2", undo: undoFn2 });
+ });
+
+ await act(async () => {
+ await result.current.undo();
+ });
+
+ expect(undoFn2).toHaveBeenCalledTimes(1);
+ expect(undoFn1).not.toHaveBeenCalled();
+ expect(result.current.canUndo).toBe(true);
+ expect(result.current.lastAction?.description).toBe("Action 1");
+ });
+
+ it("handles undo error", async () => {
+ const undoFn = jest.fn().mockRejectedValue(new Error("Undo failed"));
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({
+ description: "Deleted record",
+ undo: undoFn,
+ });
+ });
+
+ await act(async () => {
+ await expect(result.current.undo()).rejects.toThrow("Undo failed");
+ });
+
+ expect(result.current.canUndo).toBe(true);
+ expect(result.current.isUndoing).toBe(false);
+ expect(result.current.error?.message).toBe("Undo failed");
+ });
+
+ it("undo is a no-op when stack is empty", async () => {
+ const { result } = renderHook(() => useUndoRedo());
+
+ await act(async () => {
+ await result.current.undo();
+ });
+
+ expect(result.current.canUndo).toBe(false);
+ expect(result.current.isUndoing).toBe(false);
+ });
+
+ it("dismiss removes last action without undoing", () => {
+ const undoFn = jest.fn();
+ const { result } = renderHook(() => useUndoRedo());
+
+ act(() => {
+ result.current.pushAction({
+ description: "Deleted record",
+ undo: undoFn,
+ });
+ });
+
+ act(() => {
+ result.current.dismiss();
+ });
+
+ expect(undoFn).not.toHaveBeenCalled();
+ expect(result.current.canUndo).toBe(false);
+ expect(result.current.lastAction).toBeNull();
+ });
+});
diff --git a/hooks/useContextualActions.ts b/hooks/useContextualActions.ts
new file mode 100644
index 0000000..c432b6d
--- /dev/null
+++ b/hooks/useContextualActions.ts
@@ -0,0 +1,118 @@
+import { useCallback, useState } from "react";
+import * as Linking from "expo-linking";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface ContextualAction {
+ type: "phone" | "email" | "url" | "address";
+ label: string;
+ value: string;
+ field: string;
+}
+
+export interface UseContextualActionsResult {
+ /** Currently detected actions */
+ actions: ContextualAction[];
+ /** Scan fields to detect actionable items */
+ detectActions: (
+ fields: Array<{ name: string; type: string; value: unknown }>,
+ ) => ContextualAction[];
+ /** Execute an action (open URL scheme) */
+ executeAction: (action: ContextualAction) => Promise;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for contextual record actions.
+ * Detects actionable fields (phone, email, url, address) and opens
+ * the appropriate URL scheme when executed.
+ *
+ * ```ts
+ * const { actions, detectActions, executeAction } = useContextualActions();
+ * const detected = detectActions(fields);
+ * await executeAction(detected[0]);
+ * ```
+ */
+export function useContextualActions(): UseContextualActionsResult {
+ const [actions, setActions] = useState([]);
+
+ const detectActions = useCallback(
+ (
+ fields: Array<{ name: string; type: string; value: unknown }>,
+ ): ContextualAction[] => {
+ const detected: ContextualAction[] = [];
+
+ for (const field of fields) {
+ if (field.value == null || field.value === "") continue;
+ const strValue = String(field.value);
+
+ if (field.type === "phone") {
+ detected.push({
+ type: "phone",
+ label: `Call ${field.name}`,
+ value: strValue,
+ field: field.name,
+ });
+ } else if (field.type === "email") {
+ detected.push({
+ type: "email",
+ label: `Email ${field.name}`,
+ value: strValue,
+ field: field.name,
+ });
+ } else if (field.type === "url") {
+ detected.push({
+ type: "url",
+ label: `Open ${field.name}`,
+ value: strValue,
+ field: field.name,
+ });
+ } else if (field.type === "address") {
+ detected.push({
+ type: "address",
+ label: `Map ${field.name}`,
+ value: strValue,
+ field: field.name,
+ });
+ }
+ }
+
+ setActions(detected);
+ return detected;
+ },
+ [],
+ );
+
+ const executeAction = useCallback(
+ async (action: ContextualAction): Promise => {
+ let url: string;
+ switch (action.type) {
+ case "phone":
+ url = `tel:${action.value}`;
+ break;
+ case "email":
+ url = `mailto:${action.value}`;
+ break;
+ case "url":
+ url = action.value.startsWith("http")
+ ? action.value
+ : `https://${action.value}`;
+ break;
+ case "address":
+ url = `maps:?q=${encodeURIComponent(action.value)}`;
+ break;
+ default:
+ throw new Error(`Unsupported action type: ${(action as ContextualAction).type}`);
+ }
+ await Linking.openURL(url);
+ },
+ [],
+ );
+
+ return { actions, detectActions, executeAction };
+}
diff --git a/hooks/useInlineEdit.ts b/hooks/useInlineEdit.ts
new file mode 100644
index 0000000..3cd87e1
--- /dev/null
+++ b/hooks/useInlineEdit.ts
@@ -0,0 +1,121 @@
+import { useCallback, useState } from "react";
+import { useClient } from "@objectstack/client-react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface InlineEditState {
+ field: string;
+ value: unknown;
+ originalValue: unknown;
+ isDirty: boolean;
+}
+
+export interface UseInlineEditResult {
+ /** The field currently being edited */
+ editingField: string | null;
+ /** Begin editing a field on a record */
+ startEdit: (
+ object: string,
+ recordId: string,
+ field: string,
+ currentValue: unknown,
+ ) => void;
+ /** Persist the pending value to the server */
+ saveEdit: () => Promise;
+ /** Discard changes and stop editing */
+ cancelEdit: () => void;
+ /** Update the pending value */
+ updateValue: (value: unknown) => void;
+ /** The current (pending) value */
+ currentValue: unknown;
+ /** Whether the pending value differs from the original */
+ isDirty: boolean;
+ /** Whether a save operation is in progress */
+ isLoading: boolean;
+ /** Last error */
+ error: Error | null;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for inline field editing on detail views.
+ *
+ * ```ts
+ * const { startEdit, saveEdit, cancelEdit, updateValue, isDirty } = useInlineEdit();
+ * startEdit("tasks", "task-1", "title", "Old title");
+ * updateValue("New title");
+ * await saveEdit();
+ * ```
+ */
+export function useInlineEdit(): UseInlineEditResult {
+ const client = useClient();
+ const [editingField, setEditingField] = useState(null);
+ const [object, setObject] = useState("");
+ const [recordId, setRecordId] = useState("");
+ const [currentValue, setCurrentValue] = useState(undefined);
+ const [originalValue, setOriginalValue] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const startEdit = useCallback(
+ (obj: string, recId: string, field: string, value: unknown) => {
+ setObject(obj);
+ setRecordId(recId);
+ setEditingField(field);
+ setCurrentValue(value);
+ setOriginalValue(value);
+ setError(null);
+ },
+ [],
+ );
+
+ const updateValue = useCallback((value: unknown) => {
+ setCurrentValue(value);
+ }, []);
+
+ const cancelEdit = useCallback(() => {
+ setCurrentValue(originalValue);
+ setEditingField(null);
+ setError(null);
+ }, [originalValue]);
+
+ const saveEdit = useCallback(async (): Promise => {
+ if (!editingField) return;
+ setIsLoading(true);
+ setError(null);
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (client as any).api?.update(object, recordId, {
+ [editingField]: currentValue,
+ });
+ setOriginalValue(currentValue);
+ setEditingField(null);
+ } catch (err: unknown) {
+ const saveError =
+ err instanceof Error ? err : new Error("Failed to save edit");
+ setError(saveError);
+ throw saveError;
+ } finally {
+ setIsLoading(false);
+ }
+ }, [client, object, recordId, editingField, currentValue]);
+
+ const isDirty = currentValue !== originalValue;
+
+ return {
+ editingField,
+ startEdit,
+ saveEdit,
+ cancelEdit,
+ updateValue,
+ currentValue,
+ isDirty,
+ isLoading,
+ error,
+ };
+}
diff --git a/hooks/useQuickActions.ts b/hooks/useQuickActions.ts
new file mode 100644
index 0000000..36cfbd3
--- /dev/null
+++ b/hooks/useQuickActions.ts
@@ -0,0 +1,100 @@
+import { useCallback, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface QuickAction {
+ id: string;
+ label: string;
+ icon: string;
+ type: "create" | "search" | "scan" | "navigate" | "custom";
+ target?: string;
+ onExecute?: () => void;
+}
+
+export interface UseQuickActionsResult {
+ /** Available quick actions */
+ actions: QuickAction[];
+ /** Register a new quick action */
+ registerAction: (action: QuickAction) => void;
+ /** Remove a quick action by id */
+ removeAction: (id: string) => void;
+ /** Execute a quick action by id */
+ executeAction: (id: string) => Promise;
+ /** Whether an action is being executed */
+ isLoading: boolean;
+ /** Last error */
+ error: Error | null;
+}
+
+/* ------------------------------------------------------------------ */
+/* Defaults */
+/* ------------------------------------------------------------------ */
+
+const DEFAULT_ACTIONS: QuickAction[] = [
+ { id: "new-record", label: "New Record", icon: "plus", type: "create" },
+ { id: "search", label: "Search", icon: "search", type: "search" },
+ { id: "scan", label: "Scan", icon: "camera", type: "scan" },
+];
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for quick action configuration.
+ * Provides a set of default actions and allows registering / removing custom ones.
+ *
+ * ```ts
+ * const { actions, registerAction, executeAction } = useQuickActions();
+ * registerAction({ id: "import", label: "Import", icon: "upload", type: "custom", onExecute: () => {} });
+ * await executeAction("import");
+ * ```
+ */
+export function useQuickActions(): UseQuickActionsResult {
+ const [actions, setActions] = useState(DEFAULT_ACTIONS);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const registerAction = useCallback((action: QuickAction) => {
+ setActions((prev) => {
+ const exists = prev.some((a) => a.id === action.id);
+ if (exists) return prev.map((a) => (a.id === action.id ? action : a));
+ return [...prev, action];
+ });
+ }, []);
+
+ const removeAction = useCallback((id: string) => {
+ setActions((prev) => prev.filter((a) => a.id !== id));
+ }, []);
+
+ const executeAction = useCallback(
+ async (id: string): Promise => {
+ const action = actions.find((a) => a.id === id);
+ if (!action) {
+ const err = new Error(`Action not found: ${id}`);
+ setError(err);
+ throw err;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ if (action.onExecute) {
+ action.onExecute();
+ }
+ } catch (err: unknown) {
+ const execError =
+ err instanceof Error ? err : new Error("Failed to execute action");
+ setError(execError);
+ throw execError;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [actions],
+ );
+
+ return { actions, registerAction, removeAction, executeAction, isLoading, error };
+}
diff --git a/hooks/useUndoRedo.ts b/hooks/useUndoRedo.ts
new file mode 100644
index 0000000..b452d4b
--- /dev/null
+++ b/hooks/useUndoRedo.ts
@@ -0,0 +1,104 @@
+import { useCallback, useRef, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface UndoableAction {
+ id: string;
+ description: string;
+ undo: () => Promise;
+ timestamp: number;
+}
+
+export interface UseUndoRedoResult {
+ /** Whether there is an action that can be undone */
+ canUndo: boolean;
+ /** The most recent undoable action */
+ lastAction: UndoableAction | null;
+ /** Push a new undoable action onto the stack */
+ pushAction: (action: Omit) => void;
+ /** Undo the last action */
+ undo: () => Promise;
+ /** Dismiss the last action without undoing */
+ dismiss: () => void;
+ /** Whether an undo operation is in progress */
+ isUndoing: boolean;
+ /** Last error */
+ error: Error | null;
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+let counter = 0;
+function generateId(): string {
+ counter += 1;
+ return `undo-${Date.now()}-${counter}`;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Hook for undo/redo state management.
+ * Maintains a stack of undoable actions and provides undo / dismiss.
+ *
+ * ```ts
+ * const { pushAction, undo, canUndo } = useUndoRedo();
+ * pushAction({ description: "Deleted record", undo: async () => restore() });
+ * await undo();
+ * ```
+ */
+export function useUndoRedo(): UseUndoRedoResult {
+ const [stack, setStack] = useState([]);
+ const stackRef = useRef(stack);
+ stackRef.current = stack;
+
+ const [isUndoing, setIsUndoing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const lastAction = stack.length > 0 ? stack[stack.length - 1] : null;
+ const canUndo = stack.length > 0;
+
+ const pushAction = useCallback(
+ (action: Omit) => {
+ const entry: UndoableAction = {
+ ...action,
+ id: generateId(),
+ timestamp: Date.now(),
+ };
+ setStack((prev) => [...prev, entry]);
+ setError(null);
+ },
+ [],
+ );
+
+ const undo = useCallback(async (): Promise => {
+ setError(null);
+ const current = stackRef.current[stackRef.current.length - 1];
+ if (!current) return;
+
+ setIsUndoing(true);
+ try {
+ await current.undo();
+ setStack((prev) => prev.slice(0, -1));
+ } catch (err: unknown) {
+ const undoError =
+ err instanceof Error ? err : new Error("Failed to undo action");
+ setError(undoError);
+ throw undoError;
+ } finally {
+ setIsUndoing(false);
+ }
+ }, []);
+
+ const dismiss = useCallback(() => {
+ setStack((prev) => prev.slice(0, -1));
+ setError(null);
+ }, []);
+
+ return { canUndo, lastAction, pushAction, undo, dismiss, isUndoing, error };
+}
From 4d068465f67c404b20fd64fca8b69aabd53bbeee Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:50:28 +0000
Subject: [PATCH 05/11] feat: add Phase 16 hooks and utilities (form drafts,
list enhancements, haptics, micro-interactions)
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
__tests__/hooks/useFormDraft.test.ts | 166 +++++++++++++++++++++
__tests__/hooks/useListEnhancement.test.ts | 87 +++++++++++
__tests__/lib/haptics.test.ts | 59 ++++++++
__tests__/lib/micro-interactions.test.ts | 78 ++++++++++
hooks/useFormDraft.ts | 132 ++++++++++++++++
hooks/useListEnhancement.ts | 68 +++++++++
lib/haptics.ts | 52 +++++++
lib/micro-interactions.ts | 74 +++++++++
8 files changed, 716 insertions(+)
create mode 100644 __tests__/hooks/useFormDraft.test.ts
create mode 100644 __tests__/hooks/useListEnhancement.test.ts
create mode 100644 __tests__/lib/haptics.test.ts
create mode 100644 __tests__/lib/micro-interactions.test.ts
create mode 100644 hooks/useFormDraft.ts
create mode 100644 hooks/useListEnhancement.ts
create mode 100644 lib/haptics.ts
create mode 100644 lib/micro-interactions.ts
diff --git a/__tests__/hooks/useFormDraft.test.ts b/__tests__/hooks/useFormDraft.test.ts
new file mode 100644
index 0000000..bc2c853
--- /dev/null
+++ b/__tests__/hooks/useFormDraft.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Tests for useFormDraft – validates draft save/load/discard,
+ * dirty state, and completion percentage calculation.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useFormDraft } from "~/hooks/useFormDraft";
+
+describe("useFormDraft", () => {
+ it("starts with no draft and not dirty", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ expect(result.current.draft).toBeNull();
+ expect(result.current.isDirty).toBe(false);
+ expect(result.current.completionPercent).toBe(0);
+ });
+
+ it("saveDraft stores a draft and marks dirty", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Test" });
+ });
+
+ expect(result.current.draft).not.toBeNull();
+ expect(result.current.draft!.objectName).toBe("tasks");
+ expect(result.current.draft!.values).toEqual({ title: "Test" });
+ expect(result.current.draft!.savedAt).toBeGreaterThan(0);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it("saveDraft with recordId", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Edit" }, "task-1");
+ });
+
+ expect(result.current.draft!.objectName).toBe("tasks");
+ expect(result.current.draft!.recordId).toBe("task-1");
+ expect(result.current.draft!.values).toEqual({ title: "Edit" });
+ });
+
+ it("loadDraft retrieves a saved draft", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Draft" });
+ });
+
+ let loaded: unknown;
+ act(() => {
+ loaded = result.current.loadDraft("tasks");
+ });
+
+ expect(loaded).not.toBeNull();
+ expect((loaded as any).values).toEqual({ title: "Draft" });
+ });
+
+ it("loadDraft returns null for missing draft", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ let loaded: unknown;
+ act(() => {
+ loaded = result.current.loadDraft("nonexistent");
+ });
+
+ expect(loaded).toBeNull();
+ expect(result.current.draft).toBeNull();
+ });
+
+ it("discardDraft removes the draft", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "To discard" });
+ });
+ expect(result.current.hasDraft("tasks")).toBe(true);
+
+ act(() => {
+ result.current.discardDraft("tasks");
+ });
+
+ expect(result.current.draft).toBeNull();
+ expect(result.current.isDirty).toBe(false);
+ expect(result.current.hasDraft("tasks")).toBe(false);
+ });
+
+ it("hasDraft returns correct boolean", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ expect(result.current.hasDraft("tasks")).toBe(false);
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Yes" });
+ });
+
+ expect(result.current.hasDraft("tasks")).toBe(true);
+ expect(result.current.hasDraft("other")).toBe(false);
+ });
+
+ it("completionPercent reflects filled fields", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.setFieldCount(4);
+ });
+
+ act(() => {
+ result.current.saveDraft("tasks", {
+ title: "Hi",
+ description: "",
+ status: "open",
+ priority: null,
+ });
+ });
+
+ // 2 of 4 fields filled (description="" and priority=null are empty)
+ expect(result.current.completionPercent).toBe(50);
+ });
+
+ it("completionPercent is 0 when fieldCount is 0", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Hi" });
+ });
+
+ expect(result.current.completionPercent).toBe(0);
+ });
+
+ it("isDirty is false when draft has empty values", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", {});
+ });
+
+ expect(result.current.isDirty).toBe(false);
+ });
+
+ it("handles multiple drafts independently", () => {
+ const { result } = renderHook(() => useFormDraft());
+
+ act(() => {
+ result.current.saveDraft("tasks", { title: "Task" });
+ });
+ act(() => {
+ result.current.saveDraft("contacts", { name: "John" });
+ });
+
+ expect(result.current.hasDraft("tasks")).toBe(true);
+ expect(result.current.hasDraft("contacts")).toBe(true);
+
+ act(() => {
+ result.current.discardDraft("tasks");
+ });
+
+ expect(result.current.hasDraft("tasks")).toBe(false);
+ expect(result.current.hasDraft("contacts")).toBe(true);
+ });
+});
diff --git a/__tests__/hooks/useListEnhancement.test.ts b/__tests__/hooks/useListEnhancement.test.ts
new file mode 100644
index 0000000..6faef95
--- /dev/null
+++ b/__tests__/hooks/useListEnhancement.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Tests for useListEnhancement – validates density toggle,
+ * record count, view tabs, and row height calculation.
+ */
+import { renderHook, act } from "@testing-library/react-native";
+
+jest.mock("@objectstack/client-react", () => ({
+ useClient: () => ({}),
+}));
+
+import { useListEnhancement } from "~/hooks/useListEnhancement";
+
+describe("useListEnhancement", () => {
+ it("defaults to comfortable density", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ expect(result.current.density).toBe("comfortable");
+ expect(result.current.recordCount).toBeNull();
+ expect(result.current.activeViewTab).toBeNull();
+ });
+
+ it("setDensity updates density", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ act(() => {
+ result.current.setDensity("compact");
+ });
+ expect(result.current.density).toBe("compact");
+
+ act(() => {
+ result.current.setDensity("spacious");
+ });
+ expect(result.current.density).toBe("spacious");
+ });
+
+ it("getRowHeight returns correct height for compact", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ act(() => {
+ result.current.setDensity("compact");
+ });
+ expect(result.current.getRowHeight()).toBe(48);
+ });
+
+ it("getRowHeight returns correct height for comfortable", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ expect(result.current.getRowHeight()).toBe(64);
+ });
+
+ it("getRowHeight returns correct height for spacious", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ act(() => {
+ result.current.setDensity("spacious");
+ });
+ expect(result.current.getRowHeight()).toBe(80);
+ });
+
+ it("setRecordCount updates record count", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ act(() => {
+ result.current.setRecordCount(42);
+ });
+ expect(result.current.recordCount).toBe(42);
+
+ act(() => {
+ result.current.setRecordCount(null);
+ });
+ expect(result.current.recordCount).toBeNull();
+ });
+
+ it("setActiveViewTab updates the active tab", () => {
+ const { result } = renderHook(() => useListEnhancement());
+
+ act(() => {
+ result.current.setActiveViewTab("view-1");
+ });
+ expect(result.current.activeViewTab).toBe("view-1");
+
+ act(() => {
+ result.current.setActiveViewTab(null);
+ });
+ expect(result.current.activeViewTab).toBeNull();
+ });
+});
diff --git a/__tests__/lib/haptics.test.ts b/__tests__/lib/haptics.test.ts
new file mode 100644
index 0000000..ef02b55
--- /dev/null
+++ b/__tests__/lib/haptics.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Tests for lib/haptics – validates triggerHaptic maps
+ * patterns correctly to expo-haptics API calls.
+ */
+import * as Haptics from "expo-haptics";
+import { triggerHaptic } from "~/lib/haptics";
+
+describe("triggerHaptic", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("light triggers impactAsync with Light", async () => {
+ await triggerHaptic("light");
+ expect(Haptics.impactAsync).toHaveBeenCalledWith(
+ Haptics.ImpactFeedbackStyle.Light,
+ );
+ });
+
+ it("medium triggers impactAsync with Medium", async () => {
+ await triggerHaptic("medium");
+ expect(Haptics.impactAsync).toHaveBeenCalledWith(
+ Haptics.ImpactFeedbackStyle.Medium,
+ );
+ });
+
+ it("heavy triggers impactAsync with Heavy", async () => {
+ await triggerHaptic("heavy");
+ expect(Haptics.impactAsync).toHaveBeenCalledWith(
+ Haptics.ImpactFeedbackStyle.Heavy,
+ );
+ });
+
+ it("success triggers notificationAsync with Success", async () => {
+ await triggerHaptic("success");
+ expect(Haptics.notificationAsync).toHaveBeenCalledWith(
+ Haptics.NotificationFeedbackType.Success,
+ );
+ });
+
+ it("warning triggers notificationAsync with Warning", async () => {
+ await triggerHaptic("warning");
+ expect(Haptics.notificationAsync).toHaveBeenCalledWith(
+ Haptics.NotificationFeedbackType.Warning,
+ );
+ });
+
+ it("error triggers notificationAsync with Error", async () => {
+ await triggerHaptic("error");
+ expect(Haptics.notificationAsync).toHaveBeenCalledWith(
+ Haptics.NotificationFeedbackType.Error,
+ );
+ });
+
+ it("selection triggers selectionAsync", async () => {
+ await triggerHaptic("selection");
+ expect(Haptics.selectionAsync).toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/lib/micro-interactions.test.ts b/__tests__/lib/micro-interactions.test.ts
new file mode 100644
index 0000000..c71b7ec
--- /dev/null
+++ b/__tests__/lib/micro-interactions.test.ts
@@ -0,0 +1,78 @@
+/**
+ * Tests for lib/micro-interactions – validates animation configs
+ * and entrance delay calculation.
+ */
+import {
+ MicroInteractions,
+ getEntranceDelay,
+} from "~/lib/micro-interactions";
+
+describe("MicroInteractions", () => {
+ it("listItemEntrance returns spring config", () => {
+ const config = MicroInteractions.listItemEntrance(0);
+ expect(config.type).toBe("spring");
+ expect(config.damping).toBe(15);
+ expect(config.stiffness).toBe(150);
+ expect(config.mass).toBe(1);
+ });
+
+ it("buttonPress returns spring config", () => {
+ const config = MicroInteractions.buttonPress();
+ expect(config.type).toBe("spring");
+ expect(config.damping).toBe(10);
+ expect(config.stiffness).toBe(400);
+ });
+
+ it("stateChange returns timing config", () => {
+ const config = MicroInteractions.stateChange();
+ expect(config.type).toBe("timing");
+ expect(config.duration).toBe(200);
+ });
+
+ it("cardExpand returns spring config", () => {
+ const config = MicroInteractions.cardExpand();
+ expect(config.type).toBe("spring");
+ expect(config.damping).toBe(20);
+ expect(config.stiffness).toBe(200);
+ });
+
+ it("fadeIn returns timing config", () => {
+ const config = MicroInteractions.fadeIn();
+ expect(config.type).toBe("timing");
+ expect(config.duration).toBe(300);
+ });
+
+ it("fadeIn accepts optional delay parameter", () => {
+ const config = MicroInteractions.fadeIn(100);
+ expect(config.type).toBe("timing");
+ expect(config.duration).toBe(300);
+ });
+
+ it("scaleIn returns spring config", () => {
+ const config = MicroInteractions.scaleIn();
+ expect(config.type).toBe("spring");
+ expect(config.damping).toBe(12);
+ expect(config.stiffness).toBe(200);
+ });
+});
+
+describe("getEntranceDelay", () => {
+ it("returns 0 for index 0", () => {
+ expect(getEntranceDelay(0)).toBe(0);
+ });
+
+ it("returns baseDelay * index for small indices", () => {
+ expect(getEntranceDelay(3)).toBe(150);
+ expect(getEntranceDelay(5)).toBe(250);
+ });
+
+ it("caps at index 10", () => {
+ expect(getEntranceDelay(15)).toBe(500);
+ expect(getEntranceDelay(100)).toBe(500);
+ });
+
+ it("uses custom baseDelay", () => {
+ expect(getEntranceDelay(3, 100)).toBe(300);
+ expect(getEntranceDelay(15, 100)).toBe(1000);
+ });
+});
diff --git a/hooks/useFormDraft.ts b/hooks/useFormDraft.ts
new file mode 100644
index 0000000..2c8fd18
--- /dev/null
+++ b/hooks/useFormDraft.ts
@@ -0,0 +1,132 @@
+import { useCallback, useRef, useState } from "react";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface FormDraftState {
+ objectName: string;
+ recordId?: string;
+ values: Record;
+ savedAt: number;
+}
+
+export interface UseFormDraftResult {
+ /** Current draft state */
+ draft: FormDraftState | null;
+ /** Save a draft for a given object/record */
+ saveDraft: (
+ objectName: string,
+ values: Record,
+ recordId?: string,
+ ) => void;
+ /** Load a previously saved draft */
+ loadDraft: (
+ objectName: string,
+ recordId?: string,
+ ) => FormDraftState | null;
+ /** Discard (remove) a draft */
+ discardDraft: (objectName: string, recordId?: string) => void;
+ /** Check whether a draft exists */
+ hasDraft: (objectName: string, recordId?: string) => boolean;
+ /** Whether the current draft has values */
+ isDirty: boolean;
+ /** Percentage of filled fields (0-100) */
+ completionPercent: number;
+ /** Set the total number of fields for completion calculation */
+ setFieldCount: (count: number) => void;
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function draftKey(objectName: string, recordId?: string): string {
+ return recordId ? `${objectName}:${recordId}` : objectName;
+}
+
+/* ------------------------------------------------------------------ */
+/* Hook */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Auto-save form drafts with discard confirmation.
+ *
+ * Stores drafts in local state (Map keyed by objectName+recordId).
+ * Pure local state — no server calls.
+ *
+ * ```ts
+ * const { saveDraft, loadDraft, isDirty, completionPercent } = useFormDraft();
+ * saveDraft("tasks", { title: "New task" });
+ * ```
+ */
+export function useFormDraft(): UseFormDraftResult {
+ const draftsRef = useRef