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} + + +