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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Use useTabBarScrollPadding from @/lib/tab-bar-insets */
|
||||
export { useTabBarInset, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user