From 14c880123c043d4ccd5a705714f7cf44a63e4e4f Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Wed, 17 Jun 2026 22:36:37 -0400 Subject: [PATCH] Add beenvoice mobile companion app with full dark mode support. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities. Co-authored-by: Cursor --- .env.example | 3 + README.md | 83 + app.json | 39 +- app/(app)/_layout.tsx | 69 + app/(app)/index.tsx | 358 + app/(app)/invoices/[id].tsx | 395 + app/(app)/invoices/_layout.tsx | 35 + app/(app)/invoices/edit/[id].tsx | 378 + app/(app)/invoices/index.tsx | 255 + app/(app)/settings.tsx | 451 ++ app/(app)/timer.tsx | 31 + app/(auth)/_layout.tsx | 12 + app/(auth)/forgot-password.tsx | 133 + app/(auth)/index.tsx | 5 + app/(auth)/register.tsx | 191 + app/(auth)/reset-password.tsx | 182 + app/(auth)/sign-in.tsx | 179 + app/(tabs)/_layout.tsx | 70 - app/(tabs)/index.tsx | 31 - app/(tabs)/two.tsx | 31 - app/_layout.tsx | 126 +- app/modal.tsx | 35 - assets/beenvoice.icon/Assets/beenvoice.svg | 11 + assets/beenvoice.icon/icon.json | 66 + assets/images/android-icon-background.png | Bin 17549 -> 1287 bytes assets/images/android-icon-foreground.png | Bin 78796 -> 15041 bytes assets/images/android-icon-monochrome.png | Bin 4140 -> 15041 bytes assets/images/beenvoice-logo-dark.png | Bin 0 -> 44438 bytes assets/images/beenvoice-logo.png | Bin 0 -> 57466 bytes assets/images/beenvoice-logo.svg | 42 + assets/images/favicon.png | Bin 1129 -> 1535 bytes assets/images/icon.png | Bin 393493 -> 38944 bytes assets/images/splash-icon.png | Bin 17547 -> 17949 bytes bun.lock | 1448 ++++ components/AppBackground.tsx | 34 + components/AppLockOverlay.tsx | 174 + components/BrandBackground.tsx | 134 + components/ClockedInIndicator.tsx | 64 + components/CollapsibleServerField.tsx | 150 + components/ExternalLink.tsx | 4 +- components/FilterChip.tsx | 57 + components/GlassSurface.tsx | 112 + components/InstanceUrlField.tsx | 77 + components/LoadingScreen.tsx | 45 + components/Logo.tsx | 108 + components/PageHeader.tsx | 37 + components/PinPrompt.tsx | 158 + components/Screen.tsx | 38 + components/StatCard.tsx | 46 + components/StatusBadge.tsx | 33 + components/TabPage.tsx | 43 + components/TabScrollView.tsx | 46 + components/TopChrome.tsx | 29 + components/TopChromeBar.tsx | 55 + components/invoices/LineItemEditor.tsx | 190 + components/time-clock/TimeClockPanel.tsx | 523 ++ components/ui/Button.tsx | 95 + components/ui/Card.tsx | 37 + components/ui/DateTimeField.tsx | 180 + components/ui/Input.tsx | 62 + components/ui/SelectField.tsx | 204 + constants/theme.ts | 77 + contexts/AccountsContext.tsx | 211 + contexts/AppLockContext.tsx | 232 + contexts/AuthContext.tsx | 50 + contexts/ThemeContext.tsx | 88 + lib/accounts.ts | 67 + lib/app-lock.ts | 47 + lib/auth-api.ts | 52 + lib/beenvoice-theme.ts | 111 + lib/config.ts | 24 + lib/format.ts | 34 + lib/instance-url.ts | 42 + lib/invoice-status.ts | 39 + lib/layout-insets.ts | 2 + lib/live-clock.ts | 21 + lib/tab-bar-insets.ts | 50 + lib/tab-layout.ts | 17 + lib/theme-palette.ts | 99 + lib/time-clock-live-activity.ts | 110 + lib/time-clock-live-activity.types.ts | 11 + lib/time-clock.ts | 43 + lib/top-chrome-insets.ts | 15 + lib/trpc.tsx | 48 + lib/use-running-elapsed.ts | 29 + lib/use-themed-styles.ts | 13 + metro.config.js | 16 + package-lock.json | 7635 -------------------- package.json | 34 +- simulator-screenshot.png | Bin 0 -> 237682 bytes svg.d.ts | 6 + tsconfig.json | 8 +- widgets/TimeClockActivity.tsx | 73 + 93 files changed, 8849 insertions(+), 7849 deletions(-) create mode 100644 .env.example create mode 100644 README.md create mode 100644 app/(app)/_layout.tsx create mode 100644 app/(app)/index.tsx create mode 100644 app/(app)/invoices/[id].tsx create mode 100644 app/(app)/invoices/_layout.tsx create mode 100644 app/(app)/invoices/edit/[id].tsx create mode 100644 app/(app)/invoices/index.tsx create mode 100644 app/(app)/settings.tsx create mode 100644 app/(app)/timer.tsx create mode 100644 app/(auth)/_layout.tsx create mode 100644 app/(auth)/forgot-password.tsx create mode 100644 app/(auth)/index.tsx create mode 100644 app/(auth)/register.tsx create mode 100644 app/(auth)/reset-password.tsx create mode 100644 app/(auth)/sign-in.tsx delete mode 100644 app/(tabs)/_layout.tsx delete mode 100644 app/(tabs)/index.tsx delete mode 100644 app/(tabs)/two.tsx delete mode 100644 app/modal.tsx create mode 100644 assets/beenvoice.icon/Assets/beenvoice.svg create mode 100644 assets/beenvoice.icon/icon.json create mode 100644 assets/images/beenvoice-logo-dark.png create mode 100644 assets/images/beenvoice-logo.png create mode 100644 assets/images/beenvoice-logo.svg create mode 100644 bun.lock create mode 100644 components/AppBackground.tsx create mode 100644 components/AppLockOverlay.tsx create mode 100644 components/BrandBackground.tsx create mode 100644 components/ClockedInIndicator.tsx create mode 100644 components/CollapsibleServerField.tsx create mode 100644 components/FilterChip.tsx create mode 100644 components/GlassSurface.tsx create mode 100644 components/InstanceUrlField.tsx create mode 100644 components/LoadingScreen.tsx create mode 100644 components/Logo.tsx create mode 100644 components/PageHeader.tsx create mode 100644 components/PinPrompt.tsx create mode 100644 components/Screen.tsx create mode 100644 components/StatCard.tsx create mode 100644 components/StatusBadge.tsx create mode 100644 components/TabPage.tsx create mode 100644 components/TabScrollView.tsx create mode 100644 components/TopChrome.tsx create mode 100644 components/TopChromeBar.tsx create mode 100644 components/invoices/LineItemEditor.tsx create mode 100644 components/time-clock/TimeClockPanel.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Card.tsx create mode 100644 components/ui/DateTimeField.tsx create mode 100644 components/ui/Input.tsx create mode 100644 components/ui/SelectField.tsx create mode 100644 constants/theme.ts create mode 100644 contexts/AccountsContext.tsx create mode 100644 contexts/AppLockContext.tsx create mode 100644 contexts/AuthContext.tsx create mode 100644 contexts/ThemeContext.tsx create mode 100644 lib/accounts.ts create mode 100644 lib/app-lock.ts create mode 100644 lib/auth-api.ts create mode 100644 lib/beenvoice-theme.ts create mode 100644 lib/config.ts create mode 100644 lib/format.ts create mode 100644 lib/instance-url.ts create mode 100644 lib/invoice-status.ts create mode 100644 lib/layout-insets.ts create mode 100644 lib/live-clock.ts create mode 100644 lib/tab-bar-insets.ts create mode 100644 lib/tab-layout.ts create mode 100644 lib/theme-palette.ts create mode 100644 lib/time-clock-live-activity.ts create mode 100644 lib/time-clock-live-activity.types.ts create mode 100644 lib/time-clock.ts create mode 100644 lib/top-chrome-insets.ts create mode 100644 lib/trpc.tsx create mode 100644 lib/use-running-elapsed.ts create mode 100644 lib/use-themed-styles.ts create mode 100644 metro.config.js delete mode 100644 package-lock.json create mode 100644 simulator-screenshot.png create mode 100644 svg.d.ts create mode 100644 widgets/TimeClockActivity.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b06b31e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# beenvoice API base URL (no trailing slash) +# Local dev on physical iPhone: use your Mac's LAN IP, e.g. http://192.168.1.42:3000 +EXPO_PUBLIC_API_URL=http://localhost:3000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2e0b5e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# beenvoice Mobile + +Expo companion app for [beenvoice](../beenvoice) — dashboard, time clock, invoices, and account settings. + +## Prerequisites + +- [Bun](https://bun.sh) 1.3+ +- beenvoice server running (see `../beenvoice/README.md`) +- iOS development build for Live Activities (`expo-widgets`) + +## Setup + +```bash +cd beenvoice-app +bun install +cp .env.example .env +``` + +Edit `.env` and set your API URL: + +```env +# Simulator +EXPO_PUBLIC_API_URL=http://localhost:3000 + +# Physical iPhone (use your Mac's LAN IP) +EXPO_PUBLIC_API_URL=http://192.168.1.42:3000 +``` + +The beenvoice server must have the Expo auth plugin enabled (`@better-auth/expo` in `beenvoice/src/lib/auth.ts`). + +## Run + +```bash +# Terminal 1 — API server +cd ../beenvoice && bun run dev + +# Terminal 2 — mobile app (development build) +cd beenvoice-app +bun run ios +``` + +This uses port **8082** for Metro so it does not collide with other Expo projects on 8081. + +If you already built the app and only need Metro: + +```bash +bun run start -- --clear +``` + +Then open the **beenvoice** app on the simulator (not Expo Go). + +Live Activities require a native build (`bun run ios`). They do not work in Expo Go. + +After changing `assets/beenvoice.icon`, rebuild iOS: + +```bash +bunx expo prebuild --platform ios --clean +bun run ios +``` + +### Troubleshooting `PlatformConstants` / `[runtime not ready]` + +Usually one of: + +1. **Wrong Metro bundler** — another project's dev server is on the same port. Stop it or use `--port 8082`. +2. **Stale native build** — after adding native modules, rebuild: + ```bash + bunx expo prebuild --platform ios --clean + bun run ios + ``` +3. **Expo Go** — native modules like widgets need the custom dev build from `bun run ios`, not Expo Go. + +## Features + +- **Auth** — sign in, register, forgot password, reset password; multiple saved accounts +- **Dashboard** — revenue, pending, overdue, recent invoices +- **Timer** — clock in/out with client, invoice, and hourly rate; iOS Live Activity (dev build) +- **Invoices** — list, filter by status, tap to update status +- **Settings** — profile, accounts, theme (system/light/dark), server URL, sign out + +## Deep links + +- `beenvoice://reset-password?token=...` — open reset password screen with token prefilled diff --git a/app.json b/app.json index 76dbd8e..666eae8 100644 --- a/app.json +++ b/app.json @@ -1,18 +1,23 @@ { "expo": { - "name": "beenvoice-app", - "slug": "beenvoice-app", + "name": "beenvoice", + "slug": "beenvoice", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "beenvoiceapp", + "scheme": "beenvoice", "userInterfaceStyle": "automatic", "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.beenvoice.app", + "icon": "./assets/beenvoice.icon", + "infoPlist": { + "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app." + } }, "android": { "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#D9D9D9", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" @@ -25,18 +30,38 @@ "favicon": "./assets/images/favicon.png" }, "plugins": [ + "expo-dev-client", "expo-router", + "expo-secure-store", [ "expo-splash-screen", { "image": "./assets/images/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#D9D9D9" } - ] + ], + [ + "expo-widgets", + { + "groupIdentifier": "group.com.beenvoice.app" + } + ], + [ + "expo-local-authentication", + { + "faceIDPermission": "Unlock beenvoice with Face ID when returning to the app." + } + ], + "@react-native-community/datetimepicker" ], "experiments": { "typedRoutes": true + }, + "extra": { + "router": { + "origin": false + } } } } diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx new file mode 100644 index 0000000..5df1f81 --- /dev/null +++ b/app/(app)/_layout.tsx @@ -0,0 +1,69 @@ +import { DynamicColorIOS, Platform } from "react-native"; +import { NativeTabs } from "expo-router/unstable-native-tabs"; + +import { AppLockOverlay } from "@/components/AppLockOverlay"; +import { useAppTheme } from "@/contexts/ThemeContext"; +import { mutedForeground, primary } from "@/lib/beenvoice-theme"; +import { AppLockProvider } from "@/contexts/AppLockContext"; + +export default function AppLayout() { + const { colors } = useAppTheme(); + + const tintColor = + Platform.OS === "ios" + ? DynamicColorIOS({ light: primary, dark: "#FAFAFA" }) + : colors.primary; + + const labelColor = + Platform.OS === "ios" + ? DynamicColorIOS({ light: mutedForeground, dark: "#A1A1AA" }) + : colors.mutedForeground; + + const tabContentStyle = { backgroundColor: "transparent" as const }; + + return ( + + + + + Dashboard + + + + + Timer + + + + + Invoices + + + + + Settings + + + + + ); +} diff --git a/app/(app)/index.tsx b/app/(app)/index.tsx new file mode 100644 index 0000000..c518a14 --- /dev/null +++ b/app/(app)/index.tsx @@ -0,0 +1,358 @@ +import { router } from "expo-router"; +import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"; +import { Screen } from "@/components/Screen"; +import { TabPage } from "@/components/TabPage"; +import { TabScrollView } from "@/components/TabScrollView"; + +import { AppBackground } from "@/components/AppBackground"; +import { PageHeader } from "@/components/PageHeader"; +import { GlassSurface } from "@/components/GlassSurface"; +import { LoadingScreen } from "@/components/LoadingScreen"; +import { StatCard } from "@/components/StatCard"; +import { StatusBadge } from "@/components/StatusBadge"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { fonts, radii, spacing } from "@/constants/theme"; +import { useAppTheme } from "@/contexts/ThemeContext"; +import { formatCurrency, formatDate } from "@/lib/format"; +import type { ThemeColors } from "@/lib/theme-palette"; +import { useThemedStyles } from "@/lib/use-themed-styles"; +import { getInvoiceStatus } from "@/lib/invoice-status"; +import { formatElapsedHoursMinutes } from "@/lib/time-clock"; +import { useRunningElapsed } from "@/lib/use-running-elapsed"; +import { api } from "@/lib/trpc"; + +export default function DashboardScreen() { + const { colors } = useAppTheme(); + const styles = useThemedStyles(createDashboardStyles); + const statsQuery = api.dashboard.getStats.useQuery(); + const runningQuery = api.timeEntries.getRunning.useQuery(undefined, { + refetchInterval: 30_000, + }); + const runningElapsed = useRunningElapsed(runningQuery.data?.startedAt); + + if (statsQuery.isLoading) { + return ; + } + + if (statsQuery.error) { + return ( + + + + Could not load dashboard + {statsQuery.error.message} + + + + ); + } + + const stats = statsQuery.data; + if (!stats) { + return ; + } + + const running = runningQuery.data; + const revenueChange = + stats.revenueChange > 0 + ? `+${stats.revenueChange.toFixed(0)}% vs last month` + : stats.revenueChange < 0 + ? `${stats.revenueChange.toFixed(0)}% vs last month` + : "No change vs last month"; + + const maxRevenue = Math.max(...stats.revenueChartData.map((d) => d.revenue), 1); + + return ( + + + + } + refreshControl={ + { + void statsQuery.refetch(); + void runningQuery.refetch(); + }} + tintColor={colors.primary} + /> + } + > + {running ? ( + router.push("/(app)/timer")}> + + + + + + {running.description || "Timer running"} + + + {running.client?.name ?? "No client"} + {running.invoice + ? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` + : ""} + + + + {formatElapsedHoursMinutes(runningElapsed)} + + + + + ) : null} + + {stats.overdueCount > 0 ? ( + + + + {stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"} + + + Follow up on outstanding payments from the Invoices tab. + + + + ) : null} + + +