Add beenvoice mobile companion app with full dark mode support.

Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 22:36:37 -04:00
co-authored by Cursor
parent 8a7a8df477
commit 14c880123c
93 changed files with 8849 additions and 7849 deletions
+67
View File
@@ -0,0 +1,67 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const ACCOUNTS_KEY = "beenvoice:accounts";
const ACTIVE_ACCOUNT_KEY = "beenvoice:active-account-id";
const DRAFT_INSTANCE_URL_KEY = "beenvoice:draft-instance-url";
export type SavedAccount = {
id: string;
instanceUrl: string;
userId: string;
email: string;
name: string;
lastUsedAt: number;
};
export function buildAccountId(instanceUrl: string, userId: string) {
const host = instanceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
return `${host}::${userId}`;
}
export function authStoragePrefix(accountId: string) {
return `beenvoice:auth:${accountId}`;
}
export async function loadAccounts(): Promise<SavedAccount[]> {
const raw = await AsyncStorage.getItem(ACCOUNTS_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as SavedAccount[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function saveAccounts(accounts: SavedAccount[]) {
await AsyncStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
}
export async function loadActiveAccountId(): Promise<string | null> {
return AsyncStorage.getItem(ACTIVE_ACCOUNT_KEY);
}
export async function saveActiveAccountId(accountId: string | null) {
if (accountId) {
await AsyncStorage.setItem(ACTIVE_ACCOUNT_KEY, accountId);
} else {
await AsyncStorage.removeItem(ACTIVE_ACCOUNT_KEY);
}
}
export async function loadDraftInstanceUrl(): Promise<string | null> {
return AsyncStorage.getItem(DRAFT_INSTANCE_URL_KEY);
}
export async function saveDraftInstanceUrl(url: string | null) {
if (url) {
await AsyncStorage.setItem(DRAFT_INSTANCE_URL_KEY, url);
} else {
await AsyncStorage.removeItem(DRAFT_INSTANCE_URL_KEY);
}
}
export async function hasConfiguredInstanceUrl(): Promise<boolean> {
const [accounts, draft] = await Promise.all([loadAccounts(), loadDraftInstanceUrl()]);
return accounts.length > 0 || Boolean(draft);
}
+47
View File
@@ -0,0 +1,47 @@
import * as SecureStore from "expo-secure-store";
const ENABLED_KEY = "beenvoice_app_lock_enabled";
const PIN_KEY = "beenvoice_app_lock_pin";
const BIOMETRIC_KEY = "beenvoice_app_lock_biometric";
export async function getAppLockEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(ENABLED_KEY);
return value === "1";
}
export async function setAppLockEnabled(enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(ENABLED_KEY, "1");
} else {
await SecureStore.deleteItemAsync(ENABLED_KEY);
}
}
export async function getStoredPin(): Promise<string | null> {
return SecureStore.getItemAsync(PIN_KEY);
}
export async function setStoredPin(pin: string): Promise<void> {
await SecureStore.setItemAsync(PIN_KEY, pin);
}
export async function clearStoredPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_KEY);
}
export async function getBiometricEnabled(): Promise<boolean> {
const value = await SecureStore.getItemAsync(BIOMETRIC_KEY);
return value === "1";
}
export async function setBiometricEnabled(enabled: boolean): Promise<void> {
if (enabled) {
await SecureStore.setItemAsync(BIOMETRIC_KEY, "1");
} else {
await SecureStore.deleteItemAsync(BIOMETRIC_KEY);
}
}
export function isValidPin(pin: string): boolean {
return /^\d{4,6}$/.test(pin);
}
+52
View File
@@ -0,0 +1,52 @@
import { getApiUrl } from "@/lib/config";
type ApiError = { error?: string; message?: string };
async function parseError(res: Response) {
const data = (await res.json().catch(() => ({}))) as ApiError;
return data.error ?? data.message ?? "Something went wrong";
}
export async function registerAccount(input: {
firstName: string;
lastName: string;
email: string;
password: string;
}) {
const res = await fetch(`${getApiUrl()}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(await parseError(res));
}
}
export async function requestPasswordReset(email: string) {
const res = await fetch(`${getApiUrl()}/api/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error(await parseError(res));
}
const data = (await res.json()) as { message?: string };
return data.message ?? "Check your email for reset instructions.";
}
export async function resetPassword(token: string, password: string) {
const res = await fetch(`${getApiUrl()}/api/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
throw new Error(await parseError(res));
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* beenvoice mobile theme — derived from `beenvoice/src/styles/globals.css`
* and root layout `brand-background` + `components/ui/card.tsx`.
*
* Default: data-interface-theme="beenvoice", data-radius="xl", data-color-theme="slate"
*/
/** hsl(0 0% 100%) */
export const background = "#FFFFFF";
/** hsl(240 10% 3.9%) */
export const foreground = "#09090B";
/** hsl(240 5.9% 10%) */
export const primary = "#18181B";
/** hsl(0 0% 98%) */
export const primaryForeground = "#FAFAFA";
/** hsl(240 4.8% 95.9%) */
export const muted = "#F4F4F5";
/** hsl(240 3.8% 46.1%) */
export const mutedForeground = "#71717A";
/** hsl(240 5.9% 90%) */
export const border = "#E4E4E7";
/** hsl(240 5.9% 90% / 0.5) — `border-border/50` on cards */
export const border50 = "rgba(228, 228, 231, 0.5)";
/** `bg-background/80` on glass surfaces */
export const surface80 = "rgba(255, 255, 255, 0.8)";
/** `bg-background/80` on chrome (tab bar, headers) — `backdrop-blur-md` */
export const chrome80 = "rgba(255, 255, 255, 0.8)";
/** brand-background grid: `#80808012` → alpha 0x12 / 255 */
export const gridLine = "rgba(128, 128, 128, 0.0706)";
export const gridSize = 24;
/** brand-background blob: `bg-neutral-400/40` = #a3a3a3 @ 40% */
export const blobCore = "rgba(163, 163, 163, 0.4)";
/** dark mode blob: neutral-500/30 — kept for future */
export const blobCoreDark = "rgba(115, 115, 115, 0.3)";
export const blobDiameter = 800;
/**
* Tailwind blur scale (approx px):
* blur-md = 12, blur-xl = 24, blur-3xl = 64
*/
export const blur = {
md: 12,
xl: 24,
blob: 64,
} as const;
/**
* expo-blur intensity is not 1:1 with CSS px — tuned to visually match.
* backdrop-blur-xl ≈ 24px, backdrop-blur-md ≈ 12px
*/
export const blurIntensity = {
card: 45,
chrome: 28,
} as const;
/**
* Radius: beenvoice `[data-slot=card] { border-radius: var(--radius-lg) }`
* with `--radius: 1rem` (xl preference) → 16px.
* Auth/marketing cards use the same glass card component.
*/
export const radius = {
sm: 4,
md: 8,
lg: 16,
xl: 20,
button: 12,
pill: 999,
} as const;
/** shadow-sm on default card */
export const shadowSm = {
shadowColor: "#000000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
} as const;
/** shadow-md on stats cards */
export const shadowMd = {
shadowColor: "#000000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
} as const;
/** @keyframes blob — 7s ease infinite */
export const blobAnimation = {
durationMs: 7000,
keyframes: [
{ translateX: 0, translateY: 0, scale: 1 },
{ translateX: 30, translateY: -50, scale: 1.1 },
{ translateX: -20, translateY: 20, scale: 0.9 },
{ translateX: 0, translateY: 0, scale: 1 },
],
} as const;
+24
View File
@@ -0,0 +1,24 @@
import Constants from "expo-constants";
const fallbackUrl = "http://localhost:3000";
let runtimeOverride: string | null = null;
export function setRuntimeApiUrl(url: string | null) {
runtimeOverride = url?.replace(/\/$/, "") ?? null;
}
export function getApiUrl() {
if (runtimeOverride) return runtimeOverride;
const fromEnv = process.env.EXPO_PUBLIC_API_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
const hostUri = Constants.expoConfig?.hostUri;
if (hostUri) {
const host = hostUri.split(":")[0];
if (host) return `http://${host}:3000`;
}
return fallbackUrl;
}
+34
View File
@@ -0,0 +1,34 @@
export function formatCurrency(amount: number, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatDateTime(date: Date | string) {
return new Date(date).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatDuration(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
+42
View File
@@ -0,0 +1,42 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "beenvoice:instance-url";
export function normalizeInstanceUrl(input: string): string | null {
const trimmed = input.trim().replace(/\/$/, "");
if (!trimmed) return null;
let url = trimmed;
if (!/^https?:\/\//i.test(url)) {
const isLocal =
/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(url);
url = `${isLocal ? "http" : "https"}://${url}`;
}
try {
const parsed = new URL(url);
if (!parsed.hostname) return null;
return `${parsed.protocol}//${parsed.host}`;
} catch {
return null;
}
}
export async function loadStoredInstanceUrl(): Promise<string | null> {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (!stored) return null;
return normalizeInstanceUrl(stored) ?? stored.replace(/\/$/, "");
}
export async function saveStoredInstanceUrl(url: string): Promise<string> {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
}
await AsyncStorage.setItem(STORAGE_KEY, normalized);
return normalized;
}
export async function clearStoredInstanceUrl(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEY);
}
+39
View File
@@ -0,0 +1,39 @@
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue";
export function getInvoiceStatus(invoice: {
status: string;
dueDate: Date | string;
}): InvoiceStatus {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (new Date(invoice.dueDate) < new Date()) return "overdue";
return "sent";
}
export const statusLabels: Record<InvoiceStatus, string> = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
const lightStatusColors: Record<InvoiceStatus, string> = {
draft: "#6b7280",
sent: "#2563eb",
paid: "#16a34a",
overdue: "#dc2626",
};
const darkStatusColors: Record<InvoiceStatus, string> = {
draft: "#A1A1AA",
sent: "#60A5FA",
paid: "#4ADE80",
overdue: "#F87171",
};
/** @deprecated Use `getStatusColor` for theme-aware colors. */
export const statusColors = lightStatusColors;
export function getStatusColor(status: InvoiceStatus, isDark: boolean): string {
return (isDark ? darkStatusColors : lightStatusColors)[status];
}
+2
View File
@@ -0,0 +1,2 @@
/** @deprecated Use useTabBarScrollPadding from @/lib/tab-bar-insets */
export { useTabBarInset, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
function formatClockTime(date: Date) {
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export function useLiveClock() {
const [time, setTime] = useState(() => formatClockTime(new Date()));
useEffect(() => {
const tick = () => setTime(formatClockTime(new Date()));
tick();
const id = setInterval(tick, 15_000);
return () => clearInterval(id);
}, []);
return time;
}
+50
View File
@@ -0,0 +1,50 @@
import { Platform, useWindowDimensions } from "react-native";
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context";
import { spacing } from "@/constants/theme";
/** Standard UITabBar content height (home indicator is separate). */
const IOS_TAB_BAR_HEIGHT = 49;
/** Slightly less than measured inset so content sits closer to the tab bar. */
const TAB_BAR_PADDING_TRIM = spacing.sm;
/**
* Pixels between the bottom of the safe-area layout frame and the window bottom.
*/
function useBelowLayoutFrame(): number {
const { height: windowHeight } = useWindowDimensions();
const frame = useSafeAreaFrame();
return Math.max(0, windowHeight - frame.y - frame.height);
}
/** Native tab bar height excluding the home-indicator inset. */
export function useNativeTabBarHeight(): number {
const belowLayoutFrame = useBelowLayoutFrame();
const { bottom: homeIndicator } = useSafeAreaInsets();
const measured = Math.max(0, belowLayoutFrame - homeIndicator);
if (measured > 0) return measured;
return Platform.OS === "ios" ? IOS_TAB_BAR_HEIGHT : 0;
}
/**
* Bottom padding so scroll content can clear the floating native tab bar.
* Uses layout-frame measurement when available, otherwise tab bar + home indicator.
*/
export function useTabBarScrollPadding(): number {
const belowLayoutFrame = useBelowLayoutFrame();
const { bottom: homeIndicator } = useSafeAreaInsets();
const tabBar = useNativeTabBarHeight();
const raw =
belowLayoutFrame > 0 ? belowLayoutFrame : tabBar + homeIndicator;
return Math.max(tabBar + homeIndicator - TAB_BAR_PADDING_TRIM, raw - TAB_BAR_PADDING_TRIM);
}
/** @deprecated Use useTabBarScrollPadding */
export function useTabBarInset() {
return useTabBarScrollPadding();
}
+17
View File
@@ -0,0 +1,17 @@
import { StyleSheet } from "react-native";
import { spacing } from "@/constants/theme";
/** Shared spacing for tab screens — single source of truth. */
export const tabLayout = StyleSheet.create({
pageHeader: {
gap: 4,
},
scrollContent: {
paddingHorizontal: spacing.md,
},
scrollBody: {
gap: spacing.md,
marginTop: spacing.sm,
},
});
+99
View File
@@ -0,0 +1,99 @@
import type { ColorSchemeName } from "react-native";
import * as light from "@/lib/beenvoice-theme";
/** Dark palette — mirrors `globals.css` `:root.dark` */
export const dark = {
background: "#09090B",
foreground: "#FAFAFA",
primary: "#FAFAFA",
primaryForeground: "#18181B",
muted: "#27272A",
mutedForeground: "#A1A1AA",
border: "#27272A",
border50: "rgba(39, 39, 42, 0.5)",
surface80: "rgba(9, 9, 11, 0.8)",
chrome80: "rgba(9, 9, 11, 0.8)",
gridLine: "rgba(128, 128, 128, 0.12)",
blobCore: "rgba(115, 115, 115, 0.3)",
destructive: "#F87171",
destructiveForeground: "#FAFAFA",
destructiveBg: "#450A0A",
success: "#4ADE80",
successBg: "#052E16",
warning: "#FBBF24",
warningBg: "#422006",
} as const;
export type ThemeColors = {
background: string;
backgroundMuted: string;
foreground: string;
card: string;
cardGlass: string;
primary: string;
primaryForeground: string;
muted: string;
mutedForeground: string;
border: string;
borderGlass: string;
secondary: string;
secondaryForeground: string;
accent: string;
destructive: string;
destructiveForeground: string;
destructiveBg: string;
success: string;
successBg: string;
warning: string;
warningBg: string;
brand: string;
brandDark: string;
text: string;
textMuted: string;
};
export function getThemeColors(scheme: ColorSchemeName): ThemeColors {
const isDark = scheme === "dark";
const palette = isDark ? dark : null;
return {
background: palette?.background ?? light.background,
backgroundMuted: palette?.muted ?? light.muted,
foreground: palette?.foreground ?? light.foreground,
card: palette?.background ?? light.background,
cardGlass: palette?.surface80 ?? light.surface80,
primary: palette?.primary ?? light.primary,
primaryForeground: palette?.primaryForeground ?? light.primaryForeground,
muted: palette?.muted ?? light.muted,
mutedForeground: palette?.mutedForeground ?? light.mutedForeground,
border: palette?.border ?? light.border,
borderGlass: palette?.border50 ?? light.border50,
secondary: palette?.border ?? light.border,
secondaryForeground: palette?.primary ?? light.primary,
accent: palette?.muted ?? light.muted,
destructive: palette?.destructive ?? "#EF4444",
destructiveForeground: palette?.destructiveForeground ?? light.primaryForeground,
destructiveBg: palette?.destructiveBg ?? "#FEF2F2",
success: palette?.success ?? "#16A34A",
successBg: palette?.successBg ?? "#F0FDF4",
warning: palette?.warning ?? "#D97706",
warningBg: palette?.warningBg ?? "#FFFBEB",
brand: palette?.primary ?? light.primary,
brandDark: palette?.foreground ?? light.foreground,
text: palette?.foreground ?? light.foreground,
textMuted: palette?.mutedForeground ?? light.mutedForeground,
};
}
export function getBackgroundTokens(scheme: ColorSchemeName) {
const isDark = scheme === "dark";
return {
background: isDark ? dark.background : light.background,
gridLine: isDark ? dark.gridLine : light.gridLine,
blobCore: isDark ? dark.blobCore : light.blobCore,
gridSize: light.gridSize,
blobDiameter: light.blobDiameter,
blobAnimation: light.blobAnimation,
};
}
+110
View File
@@ -0,0 +1,110 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import { Platform } from "react-native";
import { formatElapsedHoursMinutes, formatElapsedSeconds } from "@/lib/time-clock";
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
type RunningEntry = {
description: string;
client?: { name: string } | null;
invoice?: { invoicePrefix: string | null; invoiceNumber: string } | null;
};
type LiveActivityHandle = {
update: (props: TimeClockActivityProps) => Promise<void>;
end: (policy?: "default" | "immediate") => Promise<void>;
};
type LiveActivityFactory = {
start: (props: TimeClockActivityProps, url?: string) => LiveActivityHandle;
getInstances: () => LiveActivityHandle[];
};
let factoryCache: LiveActivityFactory | null | undefined;
function isExpoWidgetsAvailable() {
return Platform.OS === "ios" && requireOptionalNativeModule("ExpoWidgets") != null;
}
function getFactory(): LiveActivityFactory | null {
if (factoryCache !== undefined) {
return factoryCache;
}
if (!isExpoWidgetsAvailable()) {
factoryCache = null;
return null;
}
try {
factoryCache = require("@/widgets/TimeClockActivity").default as LiveActivityFactory;
} catch {
factoryCache = null;
}
return factoryCache;
}
export function isTimeClockLiveActivitySupported() {
return getFactory() != null;
}
export function buildTimeClockActivityProps(
running: RunningEntry,
elapsedSeconds: number,
): TimeClockActivityProps {
const invoice = running.invoice;
return {
elapsed: formatElapsedSeconds(elapsedSeconds),
elapsedShort: formatElapsedHoursMinutes(elapsedSeconds),
clockTime: new Date().toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
}),
description: running.description,
clientName: running.client?.name ?? "",
invoiceLabel: invoice
? `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`
: "",
};
}
export async function syncTimeClockLiveActivity(
running: RunningEntry | null | undefined,
elapsedSeconds: number,
) {
const factory = getFactory();
if (!factory) return;
if (!running) {
await endTimeClockLiveActivity();
return;
}
try {
const props = buildTimeClockActivityProps(running, elapsedSeconds);
const instances = factory.getInstances();
if (instances.length > 0) {
await instances[0]!.update(props);
return;
}
factory.start(props, "beenvoice://timer");
} catch {
// Native module can disappear between checks (e.g. hot reload in Expo Go).
factoryCache = undefined;
}
}
export async function endTimeClockLiveActivity() {
const factory = getFactory();
if (!factory) return;
try {
const instances = factory.getInstances();
await Promise.all(instances.map((instance) => instance.end("immediate")));
} catch {
factoryCache = undefined;
}
}
+11
View File
@@ -0,0 +1,11 @@
export type TimeClockActivityProps = {
/** Full elapsed timer, e.g. 01:23:45 */
elapsed: string;
/** Hours:minutes only for compact chrome, e.g. 1:23 */
elapsedShort: string;
/** Current time, hours:minutes */
clockTime: string;
description: string;
clientName: string;
invoiceLabel: string;
};
+43
View File
@@ -0,0 +1,43 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
}
/** Hours and minutes only — for Live Activity / compact displays. */
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
export function describeClockOutOutcome(input: {
outcome: ClockOutOutcome;
hours: number;
rate: number;
invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
}): string {
const amount = input.hours * input.rate;
switch (input.outcome) {
case "linked_to_invoice":
if (input.invoice) {
const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to ${label}`;
}
return `Added ${input.hours}h to invoice`;
case "saved_no_invoice":
return `Saved ${input.hours}h — no open invoice for this client.`;
case "saved_no_client":
return `Saved ${input.hours}h — pick a client and invoice to bill.`;
case "zero_hours":
return "Timer stopped.";
}
}
+15
View File
@@ -0,0 +1,15 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { spacing } from "@/constants/theme";
/** Matches `TopChrome` row height. */
export const TOP_CHROME_ROW_HEIGHT = 40;
/** Bottom inset below the chrome row (`TopChromeBar` `paddingBottom`). */
export const TOP_CHROME_PADDING_BOTTOM = spacing.xs;
/** Total height of the blurred status-bar chrome (safe area + content row). */
export function useTopChromeHeight(): number {
const { top } = useSafeAreaInsets();
return top + TOP_CHROME_ROW_HEIGHT + TOP_CHROME_PADDING_BOTTOM;
}
+48
View File
@@ -0,0 +1,48 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import SuperJSON from "superjson";
import { useAuthClient } from "@/contexts/AuthContext";
import type { AppRouter } from "beenvoice/server/api/root";
export const api = createTRPCReact<AppRouter>();
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
}
export function TRPCProvider({ apiUrl, children }: { apiUrl: string; children: ReactNode }) {
const authClient = useAuthClient();
const [queryClient] = useState(createQueryClient);
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: `${apiUrl}/api/trpc`,
transformer: SuperJSON,
headers() {
const cookie = (
authClient as { getCookie?: () => string | null | undefined }
).getCookie?.();
return cookie ? { cookie } : {};
},
}),
],
}),
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</api.Provider>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
/** Live elapsed seconds since `startedAt`, ticking every second. */
export function useRunningElapsed(startedAt?: string | Date | null) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!startedAt) {
setElapsed(0);
return;
}
const startMs = new Date(startedAt).getTime();
if (Number.isNaN(startMs)) {
setElapsed(0);
return;
}
const tick = () => {
setElapsed(Math.max(0, Math.floor((Date.now() - startMs) / 1000)));
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [startedAt]);
return elapsed;
}
+13
View File
@@ -0,0 +1,13 @@
import { useMemo } from "react";
import { StyleSheet } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
/** StyleSheet factory that re-runs when light/dark palette changes. */
export function useThemedStyles<T extends StyleSheet.NamedStyles<T>>(
factory: (colors: ThemeColors, isDark: boolean) => T,
): T {
const { colors, isDark } = useAppTheme();
return useMemo(() => StyleSheet.create(factory(colors, isDark)), [colors, isDark]);
}