diff --git a/app.json b/app.json index ab97ff7..b4daaa1 100644 --- a/app.json +++ b/app.json @@ -10,7 +10,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.beenvoice.app", - "buildNumber": "23", + "buildNumber": "26", "icon": "./assets/beenvoice.icon", "infoPlist": { "ITSAppUsesNonExemptEncryption": false, diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx index 3836ab5..80a5234 100644 --- a/app/(app)/index.tsx +++ b/app/(app)/index.tsx @@ -48,7 +48,7 @@ export default function DashboardScreen() { return ; } - if (statsQuery.error) { + if (statsQuery.error && !statsQuery.data) { return ( diff --git a/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg b/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg new file mode 100644 index 0000000..21d82ea --- /dev/null +++ b/assets/beenvoice.icon/Assets/5x5-solid-lines-grid(1).svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/beenvoice.icon/icon.json b/assets/beenvoice.icon/icon.json index ec0270e..976ea87 100644 --- a/assets/beenvoice.icon/icon.json +++ b/assets/beenvoice.icon/icon.json @@ -1,6 +1,9 @@ { "fill" : { - "automatic-gradient" : "gray:0.75000,1.00000", + "linear-gradient" : [ + "display-p3:0.75855,0.75855,0.75855,1.00000", + "display-p3:0.47359,0.47359,0.47359,1.00000" + ], "orientation" : { "start" : { "x" : 0.5, @@ -19,13 +22,26 @@ "fill-specializations" : [ { "value" : { - "solid" : "extended-gray:0.00000,1.00000" + "linear-gradient" : [ + "display-p3:0.36520,0.36520,0.36520,1.00000", + "extended-gray:0.00000,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.4999999999999998, + "y" : 0 + }, + "stop" : { + "x" : 0.4999999999999998, + "y" : 0.5617755083064717 + } + } } }, { "appearance" : "dark", "value" : { - "solid" : "extended-gray:1.00000,1.00000" + "solid" : "extended-gray:0.75000,1.00000" } }, { @@ -45,6 +61,35 @@ 0 ] } + }, + { + "blend-mode" : "normal", + "fill" : { + "linear-gradient" : [ + "display-p3:0.59424,0.59424,0.59424,1.00000", + "display-p3:0.33555,0.33555,0.33555,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "glass" : false, + "image-name" : "5x5-solid-lines-grid(1).svg", + "name" : "5x5-solid-lines-grid(1)", + "position" : { + "scale" : 1.25, + "translation-in-points" : [ + 0, + 0 + ] + } } ], "shadow" : { diff --git a/contexts/AccountsContext.tsx b/contexts/AccountsContext.tsx index ea36121..a5f55bc 100644 --- a/contexts/AccountsContext.tsx +++ b/contexts/AccountsContext.tsx @@ -22,6 +22,7 @@ import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL, invalidServerUrlMessage } import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage"; import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url"; import { migrateStoredOfficialUrls } from "@/lib/official-url-migration"; +import { clearOfflineQueryCacheForAccount } from "@/lib/offline-cache"; import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs"; export type RemoveAccountResult = { @@ -159,6 +160,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) { const wasActive = activeAccountId === accountId; await clearAuthStorage(authStoragePrefix(accountId)); + await clearOfflineQueryCacheForAccount(accountId); await clearTimeClockPrefsForAccount(accountId); const nextAccounts = accounts.filter((account) => account.id !== accountId); diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 195f7e8..4399070 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -11,16 +11,39 @@ import { type AuthClient = ReturnType; +async function authFetch(input: RequestInfo | URL, init?: RequestInit) { + try { + return await fetch(input, init); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw error; + } + + return new Response( + JSON.stringify({ + message: "Could not connect to the server.", + code: "NETWORK_ERROR", + }), + { + status: 503, + statusText: "Service Unavailable", + headers: { "content-type": "application/json" }, + }, + ); + } +} + function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient { return createAuthClient({ baseURL: apiUrl, + fetchOptions: { + customFetchImpl: authFetch, + }, plugins: [ expoClient({ scheme: "beenvoice", storagePrefix, storage: SecureStore, - // Avoid showing a cached session when cookies have already expired. - disableCache: true, }), genericOAuthClient(), ], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c1116a..e4f4ed9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -126,6 +126,16 @@ httpBatchLink({ Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`. +## Offline mode + +Read queries are cached per account + API URL in AsyncStorage (`lib/offline-cache.ts`). `TRPCProvider` restores that cache before mounting app screens, then persists successful query data for up to 7 days. SuperJSON is used for persistence so API payloads keep Date values and other transformed types. + +`lib/network-status.ts` connects `expo-network` to TanStack Query's `onlineManager`. When the device is offline, queries pause instead of repeatedly failing; when connectivity returns, React Query reconnect behavior refreshes stale data. The better-auth Expo client also keeps its SecureStore session cache enabled so a previously signed-in account can get through app boot while offline, then reconcile with the server when connectivity returns. + +Account removal clears that account's persisted query cache alongside SecureStore auth and time-clock preferences. + +Current offline scope: previously loaded dashboard, invoices, clients, businesses, expenses, reports, recurring invoices, and time entries can be viewed offline from cache. Mutations still require the API; timer actions, invoice edits/status changes, receipt uploads, reminders, emails, and onboarding writes are not persisted as an offline queue yet because they need conflict and side-effect rules. + ## App lock (per account) `lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`: diff --git a/lib/network-status.ts b/lib/network-status.ts new file mode 100644 index 0000000..715f05c --- /dev/null +++ b/lib/network-status.ts @@ -0,0 +1,34 @@ +import * as Network from "expo-network"; +import { onlineManager } from "@tanstack/react-query"; + +function isOnline({ + isConnected, + isInternetReachable, +}: Pick) { + if (isConnected === false) return false; + if (isInternetReachable === false) return false; + return true; +} + +let configured = false; + +export function configureReactQueryOnlineManager() { + if (configured) return; + configured = true; + + void Network.getNetworkStateAsync() + .then((state) => { + onlineManager.setOnline(isOnline(state)); + }) + .catch(() => { + onlineManager.setOnline(true); + }); + + onlineManager.setEventListener((setOnline) => { + const subscription = Network.addNetworkStateListener((state) => { + setOnline(isOnline(state)); + }); + + return () => subscription.remove(); + }); +} diff --git a/lib/offline-cache.ts b/lib/offline-cache.ts new file mode 100644 index 0000000..3f3f986 --- /dev/null +++ b/lib/offline-cache.ts @@ -0,0 +1,132 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + dehydrate, + hydrate, + type DehydratedState, + type QueryClient, +} from "@tanstack/react-query"; +import SuperJSON from "superjson"; + +const CACHE_PREFIX = "beenvoice:query-cache:v1"; +const CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; +const SAVE_DEBOUNCE_MS = 1000; + +type PersistedQueryCache = { + timestamp: number; + state: DehydratedState; +}; + +const SKIPPED_QUERY_KEY_PARTS = ["previewPdf"]; + +function cacheScopeKey(accountId: string | null, apiUrl: string) { + const scope = `${accountId ?? "guest"}:${apiUrl}`; + return encodeURIComponent(scope).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +export function offlineQueryCacheKey(accountId: string | null, apiUrl: string) { + return `${CACHE_PREFIX}:${cacheScopeKey(accountId, apiUrl)}`; +} + +export async function restoreOfflineQueryCache({ + queryClient, + accountId, + apiUrl, +}: { + queryClient: QueryClient; + accountId: string | null; + apiUrl: string; +}) { + const key = offlineQueryCacheKey(accountId, apiUrl); + const cached = await AsyncStorage.getItem(key); + if (!cached) return; + + try { + const persisted = SuperJSON.parse(cached); + if (Date.now() - persisted.timestamp > CACHE_MAX_AGE_MS) { + await AsyncStorage.removeItem(key); + return; + } + + hydrate(queryClient, persisted.state); + } catch { + await AsyncStorage.removeItem(key); + } +} + +export function persistOfflineQueryCache({ + queryClient, + accountId, + apiUrl, +}: { + queryClient: QueryClient; + accountId: string | null; + apiUrl: string; +}) { + const key = offlineQueryCacheKey(accountId, apiUrl); + let timeout: ReturnType | null = null; + + const save = async () => { + const state = dehydrate(queryClient, { + shouldDehydrateQuery: (query) => + query.state.status === "success" && + query.state.fetchStatus === "idle" && + query.state.fetchFailureCount === 0 && + !SKIPPED_QUERY_KEY_PARTS.some((part) => + JSON.stringify(query.queryKey).includes(part), + ), + }); + if (state.queries.length === 0) return; + + const existing = await AsyncStorage.getItem(key).then((cached) => { + if (!cached) return null; + try { + return SuperJSON.parse(cached); + } catch { + return null; + } + }); + + const queriesByHash = new Map( + existing?.state.queries.map((query) => [query.queryHash, query]), + ); + for (const query of state.queries) { + queriesByHash.set(query.queryHash, query); + } + + const persisted: PersistedQueryCache = { + timestamp: Date.now(), + state: { + mutations: state.mutations, + queries: Array.from(queriesByHash.values()), + }, + }; + + await AsyncStorage.setItem(key, SuperJSON.stringify(persisted)); + }; + + const scheduleSave = () => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(save, SAVE_DEBOUNCE_MS); + }; + + const unsubscribe = queryClient.getQueryCache().subscribe(scheduleSave); + + return () => { + unsubscribe(); + if (timeout) { + clearTimeout(timeout); + void save(); + } + }; +} + +export async function clearOfflineQueryCacheForAccount(accountId: string) { + const keys = await AsyncStorage.getAllKeys(); + const accountPrefix = `${CACHE_PREFIX}:${encodeURIComponent(`${accountId}:`)}`; + const matchingKeys = keys.filter((key) => key.startsWith(accountPrefix)); + if (matchingKeys.length > 0) { + await AsyncStorage.multiRemove(matchingKeys); + } +} diff --git a/lib/query-client.ts b/lib/query-client.ts index 21e8b36..faf27de 100644 --- a/lib/query-client.ts +++ b/lib/query-client.ts @@ -15,6 +15,8 @@ export function createAppQueryClient(onUnauthorized: () => void) { defaultOptions: { queries: { staleTime: 30_000, + gcTime: 1000 * 60 * 60 * 24 * 7, + refetchOnReconnect: true, retry: (failureCount, error) => { if (isUnauthorizedError(error) || isRateLimitError(error)) return false; return failureCount < 1; diff --git a/lib/trpc.tsx b/lib/trpc.tsx index 073cd02..8a53fc0 100644 --- a/lib/trpc.tsx +++ b/lib/trpc.tsx @@ -9,9 +9,15 @@ import { } from "react"; import SuperJSON from "superjson"; +import { LoadingScreen } from "@/components/LoadingScreen"; import { useAccounts } from "@/contexts/AccountsContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { getAuthCookieHeaders } from "@/lib/auth-cookie"; +import { configureReactQueryOnlineManager } from "@/lib/network-status"; +import { + persistOfflineQueryCache, + restoreOfflineQueryCache, +} from "@/lib/offline-cache"; import { performAuthReset } from "@/lib/auth-session"; import { createAppQueryClient } from "@/lib/query-client"; import type { AppRouter } from "beenvoice/server/api/root"; @@ -31,6 +37,7 @@ export function TRPCProvider({ const { refetch } = useSession(); const authStoragePrefixRef = useRef(authStoragePrefix); authStoragePrefixRef.current = authStoragePrefix; + const [cacheReady, setCacheReady] = useState(false); const mountedRef = useRef(true); const resettingRef = useRef(false); @@ -87,6 +94,43 @@ export function TRPCProvider({ }), ); + useEffect(() => { + configureReactQueryOnlineManager(); + }, []); + + useEffect(() => { + let cancelled = false; + setCacheReady(false); + + restoreOfflineQueryCache({ + queryClient, + accountId: activeAccountId, + apiUrl, + }).finally(() => { + if (!cancelled && mountedRef.current) { + setCacheReady(true); + } + }); + + return () => { + cancelled = true; + }; + }, [activeAccountId, apiUrl, queryClient]); + + useEffect(() => { + if (!cacheReady) return; + + return persistOfflineQueryCache({ + queryClient, + accountId: activeAccountId, + apiUrl, + }); + }, [activeAccountId, apiUrl, cacheReady, queryClient]); + + if (!cacheReady) { + return ; + } + return ( {children} diff --git a/widgets/TimeClockActivity.tsx b/widgets/TimeClockActivity.tsx index cd4f494..9b19963 100644 --- a/widgets/TimeClockActivity.tsx +++ b/widgets/TimeClockActivity.tsx @@ -8,6 +8,7 @@ import { minimumScaleFactor, monospacedDigit, padding, + truncationMode, widgetAccentedRenderingMode, } from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets"; @@ -30,20 +31,24 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi layoutPriority(2), frame({ width: 100, alignment: "center" }), ]; + // A live timer Text reserves a large ideal width (for the H:MM:SS format at + // the Dynamic Island's font), so with no width cap it inflates the compact + // pill to full width. Pin it to a snug fixed width sized for the common + // MM:SS case; minimumScaleFactor lets the occasional H:MM:SS scale down to + // fit instead of widening the pill. This keeps the pill tight with no gap. const compactTimerMods = [ font({ design: "monospaced", weight: "semibold", size: 11 }), monospacedDigit(), foregroundStyle(island), lineLimit(1), - minimumScaleFactor(0.75), - frame({ minWidth: 38, alignment: "center" }), - layoutPriority(2), + minimumScaleFactor(0.6), + frame({ width: 40, alignment: "center" }), ]; const clientMods = [ font({ weight: "bold", size: 17 }), foregroundStyle({ type: "hierarchical", style: "primary" }), lineLimit(1), - minimumScaleFactor(0.75), + truncationMode("tail"), ]; // Avoid greedy layout primitives here: the live timerInterval Text can // collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the @@ -60,8 +65,26 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi frame({ maxWidth: Infinity, alignment: "center" }), ]; - const bannerTimer = {props.elapsedShort}; - const compactTimer = {props.elapsedShort}; + const startedAt = new Date(props.startedAtMs); + // Bounded to 24h so SwiftUI reserves width for the H:MM:SS format only. + // (An open-ended `.timer`/`.date` style reserves width for a huge duration, + // which blows the compact Dynamic Island pill out to full width.) + const timerRange = { + lower: startedAt, + upper: new Date(props.startedAtMs + 24 * 60 * 60 * 1000), + }; + + // Banner (Notification Center / Lock Screen): timerInterval inside a fixed + // 100pt box, which absorbs the reserved width. + const bannerTimer = ( + + ); + // Dynamic Island (compact): same bounded timerInterval, sized intrinsically — + // no fixed/minWidth frame or layoutPriority, so the pill hugs the reserved + // H:MM:SS width instead of being padded out or stretched. + const compactTimer = ( + + ); const logoLarge = ( ); + + // Apple Watch / CarPlay (`bannerSmall`). The strip is only ~150-180pt wide, + // so it cannot use the iPhone banner's fixed 100/140/100 columns (those + // overflow and push the timer off-screen, leaving just its first digit). + // Instead: fixed-size logo, a flexible client name that truncates to fill + // the leftover space, and a compact fixed-width timer that stays fully + // visible (fixed width — not maxWidth — so the live timer text doesn't + // collapse). + const watchRowMods = [ + padding({ horizontal: 10, vertical: 6 }), + frame({ maxWidth: Infinity, alignment: "center" }), + ]; + const watchTitleMods = [ + font({ weight: "semibold", size: 14 }), + foregroundStyle({ type: "hierarchical", style: "primary" }), + lineLimit(1), + truncationMode("tail"), + frame({ maxWidth: Infinity, alignment: "leading" }), + ]; + const watchTimerMods = [ + font({ design: "monospaced", weight: "bold", size: 14 }), + monospacedDigit(), + foregroundStyle({ type: "hierarchical", style: "primary" }), + lineLimit(1), + minimumScaleFactor(0.6), + frame({ width: 58, alignment: "trailing" }), + ]; + const watchLogo = ( + + ); + const watchTimer = ( + + ); return { banner: ( @@ -95,14 +160,10 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi ), bannerSmall: ( - - - {logoLarge} - - - {title} - - {bannerTimer} + + {watchLogo} + {title} + {watchTimer} ), compactLeading: logoSmall,