Archived
Add offline caching and refine Live Activity
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.beenvoice.app",
|
"bundleIdentifier": "com.beenvoice.app",
|
||||||
"buildNumber": "23",
|
"buildNumber": "26",
|
||||||
"icon": "./assets/beenvoice.icon",
|
"icon": "./assets/beenvoice.icon",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ export default function DashboardScreen() {
|
|||||||
return <LoadingScreen message="Loading home…" />;
|
return <LoadingScreen message="Loading home…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statsQuery.error) {
|
if (statsQuery.error && !statsQuery.data) {
|
||||||
return (
|
return (
|
||||||
<AppBackground>
|
<AppBackground>
|
||||||
<Screen>
|
<Screen>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;">
|
||||||
|
<g id="grid-lines">
|
||||||
|
<path d="M0,0L0,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M200,0L200,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M400,0L400,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M600,0L600,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M800,0L800,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M1000,0L1000,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,0L1000,0" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,200L1000,200" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,400L1000,400" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,600L1000,600" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,800L1000,800" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
<path d="M0,1000L1000,1000" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:10px;"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"fill" : {
|
"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" : {
|
"orientation" : {
|
||||||
"start" : {
|
"start" : {
|
||||||
"x" : 0.5,
|
"x" : 0.5,
|
||||||
@@ -19,13 +22,26 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"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",
|
"appearance" : "dark",
|
||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "extended-gray:1.00000,1.00000"
|
"solid" : "extended-gray:0.75000,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,6 +61,35 @@
|
|||||||
0
|
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" : {
|
"shadow" : {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { setRuntimeApiUrl, getApiUrl, DEFAULT_API_URL, invalidServerUrlMessage }
|
|||||||
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
|
import { clearAuthStorage, readStoredSessionUser } from "@/lib/auth-storage";
|
||||||
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
||||||
import { migrateStoredOfficialUrls } from "@/lib/official-url-migration";
|
import { migrateStoredOfficialUrls } from "@/lib/official-url-migration";
|
||||||
|
import { clearOfflineQueryCacheForAccount } from "@/lib/offline-cache";
|
||||||
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
|
import { clearTimeClockPrefsForAccount } from "@/lib/time-clock-prefs";
|
||||||
|
|
||||||
export type RemoveAccountResult = {
|
export type RemoveAccountResult = {
|
||||||
@@ -159,6 +160,7 @@ export function AccountsProvider({ children }: { children: ReactNode }) {
|
|||||||
const wasActive = activeAccountId === accountId;
|
const wasActive = activeAccountId === accountId;
|
||||||
|
|
||||||
await clearAuthStorage(authStoragePrefix(accountId));
|
await clearAuthStorage(authStoragePrefix(accountId));
|
||||||
|
await clearOfflineQueryCacheForAccount(accountId);
|
||||||
await clearTimeClockPrefsForAccount(accountId);
|
await clearTimeClockPrefsForAccount(accountId);
|
||||||
|
|
||||||
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||||
|
|||||||
@@ -11,16 +11,39 @@ import {
|
|||||||
|
|
||||||
type AuthClient = ReturnType<typeof createAuthClient>;
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
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 {
|
function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient {
|
||||||
return createAuthClient({
|
return createAuthClient({
|
||||||
baseURL: apiUrl,
|
baseURL: apiUrl,
|
||||||
|
fetchOptions: {
|
||||||
|
customFetchImpl: authFetch,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
expoClient({
|
expoClient({
|
||||||
scheme: "beenvoice",
|
scheme: "beenvoice",
|
||||||
storagePrefix,
|
storagePrefix,
|
||||||
storage: SecureStore,
|
storage: SecureStore,
|
||||||
// Avoid showing a cached session when cookies have already expired.
|
|
||||||
disableCache: true,
|
|
||||||
}),
|
}),
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ httpBatchLink({
|
|||||||
|
|
||||||
Query defaults: `staleTime: 30_000`, `retry: 1`. Usage: `import { api } from "@/lib/trpc"`.
|
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)
|
## App lock (per account)
|
||||||
|
|
||||||
`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
|
`lib/app-lock.ts` — SecureStore keys scoped by `activeAccountId`:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as Network from "expo-network";
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function isOnline({
|
||||||
|
isConnected,
|
||||||
|
isInternetReachable,
|
||||||
|
}: Pick<Network.NetworkState, "isConnected" | "isInternetReachable">) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<PersistedQueryCache>(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<typeof setTimeout> | 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<PersistedQueryCache>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export function createAppQueryClient(onUnauthorized: () => void) {
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
gcTime: 1000 * 60 * 60 * 24 * 7,
|
||||||
|
refetchOnReconnect: true,
|
||||||
retry: (failureCount, error) => {
|
retry: (failureCount, error) => {
|
||||||
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
if (isUnauthorizedError(error) || isRateLimitError(error)) return false;
|
||||||
return failureCount < 1;
|
return failureCount < 1;
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
import { useAccounts } from "@/contexts/AccountsContext";
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
import { getAuthCookieHeaders } from "@/lib/auth-cookie";
|
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 { performAuthReset } from "@/lib/auth-session";
|
||||||
import { createAppQueryClient } from "@/lib/query-client";
|
import { createAppQueryClient } from "@/lib/query-client";
|
||||||
import type { AppRouter } from "beenvoice/server/api/root";
|
import type { AppRouter } from "beenvoice/server/api/root";
|
||||||
@@ -31,6 +37,7 @@ export function TRPCProvider({
|
|||||||
const { refetch } = useSession();
|
const { refetch } = useSession();
|
||||||
const authStoragePrefixRef = useRef(authStoragePrefix);
|
const authStoragePrefixRef = useRef(authStoragePrefix);
|
||||||
authStoragePrefixRef.current = authStoragePrefix;
|
authStoragePrefixRef.current = authStoragePrefix;
|
||||||
|
const [cacheReady, setCacheReady] = useState(false);
|
||||||
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const resettingRef = useRef(false);
|
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 <LoadingScreen message="Loading saved data…" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
minimumScaleFactor,
|
minimumScaleFactor,
|
||||||
monospacedDigit,
|
monospacedDigit,
|
||||||
padding,
|
padding,
|
||||||
|
truncationMode,
|
||||||
widgetAccentedRenderingMode,
|
widgetAccentedRenderingMode,
|
||||||
} from "@expo/ui/swift-ui/modifiers";
|
} from "@expo/ui/swift-ui/modifiers";
|
||||||
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
||||||
@@ -30,20 +31,24 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
|||||||
layoutPriority(2),
|
layoutPriority(2),
|
||||||
frame({ width: 100, alignment: "center" }),
|
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 = [
|
const compactTimerMods = [
|
||||||
font({ design: "monospaced", weight: "semibold", size: 11 }),
|
font({ design: "monospaced", weight: "semibold", size: 11 }),
|
||||||
monospacedDigit(),
|
monospacedDigit(),
|
||||||
foregroundStyle(island),
|
foregroundStyle(island),
|
||||||
lineLimit(1),
|
lineLimit(1),
|
||||||
minimumScaleFactor(0.75),
|
minimumScaleFactor(0.6),
|
||||||
frame({ minWidth: 38, alignment: "center" }),
|
frame({ width: 40, alignment: "center" }),
|
||||||
layoutPriority(2),
|
|
||||||
];
|
];
|
||||||
const clientMods = [
|
const clientMods = [
|
||||||
font({ weight: "bold", size: 17 }),
|
font({ weight: "bold", size: 17 }),
|
||||||
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||||
lineLimit(1),
|
lineLimit(1),
|
||||||
minimumScaleFactor(0.75),
|
truncationMode("tail"),
|
||||||
];
|
];
|
||||||
// Avoid greedy layout primitives here: the live timerInterval Text can
|
// Avoid greedy layout primitives here: the live timerInterval Text can
|
||||||
// collapse when paired with Spacer/maxWidth. Fixed centered boxes keep the
|
// 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" }),
|
frame({ maxWidth: Infinity, alignment: "center" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const bannerTimer = <Text modifiers={timerMods}>{props.elapsedShort}</Text>;
|
const startedAt = new Date(props.startedAtMs);
|
||||||
const compactTimer = <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>;
|
// 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 = (
|
||||||
|
<Text modifiers={timerMods} timerInterval={timerRange} countsDown={false} />
|
||||||
|
);
|
||||||
|
// 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 = (
|
||||||
|
<Text modifiers={compactTimerMods} timerInterval={timerRange} countsDown={false} />
|
||||||
|
);
|
||||||
const logoLarge = (
|
const logoLarge = (
|
||||||
<Image
|
<Image
|
||||||
assetName="SplashScreenLogo"
|
assetName="SplashScreenLogo"
|
||||||
@@ -82,6 +105,48 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
|||||||
modifiers={[frame({ width: 14, height: 14 }), widgetAccentedRenderingMode("fullColor")]}
|
modifiers={[frame({ width: 14, height: 14 }), widgetAccentedRenderingMode("fullColor")]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 = (
|
||||||
|
<Image
|
||||||
|
assetName="SplashScreenLogo"
|
||||||
|
systemName="dollarsign.circle.fill"
|
||||||
|
size={16}
|
||||||
|
modifiers={[
|
||||||
|
foregroundStyle({ type: "hierarchical", style: "primary" }),
|
||||||
|
frame({ width: 16, height: 16 }),
|
||||||
|
widgetAccentedRenderingMode("fullColor"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const watchTimer = (
|
||||||
|
<Text modifiers={watchTimerMods} timerInterval={timerRange} countsDown={false} />
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
banner: (
|
banner: (
|
||||||
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
|
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
|
||||||
@@ -95,14 +160,10 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
|
|||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
bannerSmall: (
|
bannerSmall: (
|
||||||
<HStack alignment="center" spacing={8} modifiers={bannerRowMods}>
|
<HStack alignment="center" spacing={6} modifiers={watchRowMods}>
|
||||||
<VStack alignment="center" modifiers={sideZoneMods}>
|
{watchLogo}
|
||||||
{logoLarge}
|
<Text modifiers={watchTitleMods}>{title}</Text>
|
||||||
</VStack>
|
{watchTimer}
|
||||||
<VStack alignment="center" spacing={1} modifiers={titleRowMods}>
|
|
||||||
<Text modifiers={clientMods}>{title}</Text>
|
|
||||||
</VStack>
|
|
||||||
{bannerTimer}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
compactLeading: logoSmall,
|
compactLeading: logoSmall,
|
||||||
|
|||||||
Reference in New Issue
Block a user