diff --git a/apps/mobile/.claude/settings.json b/apps/mobile/.claude/settings.json new file mode 100644 index 0000000..176e6a5 --- /dev/null +++ b/apps/mobile/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "expo@claude-plugins-official": true + } +} diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..d84b729 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,4 @@ +# beenvoice API base URL (no trailing slash) +# Omit or leave unset in production builds — app defaults to https://beenvoice.app +# 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/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..87712f0 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,44 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +.ios-release.env +dist/ios-release/ + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/apps/mobile/.ios-release.env.example b/apps/mobile/.ios-release.env.example new file mode 100644 index 0000000..c2ebd6d --- /dev/null +++ b/apps/mobile/.ios-release.env.example @@ -0,0 +1,31 @@ +# Copy to .ios-release.env and fill in (file is gitignored). +# Used by: bun run ios:release + +# Apple Developer team ID (10 chars, Membership details in developer.apple.com) +APPLE_TEAM_ID= + +# Before export: create an Apple Distribution cert in Xcode +# (Settings → Accounts → your team → Manage Certificates → + → Apple Distribution) +# +# If export fails with "profile doesn't include signing certificate", regenerate App Store +# profiles at developer.apple.com for com.beenvoice.app and com.beenvoice.app.ExpoWidgetsTarget, +# Optional overrides when those App Store profile names differ from the bundle IDs: +# IOS_MAIN_APPSTORE_PROFILE_NAME=com.beenvoice.app +# IOS_WIDGET_APPSTORE_PROFILE_NAME=com.beenvoice.app.ExpoWidgetsTarget +# then re-run the full release (not --export-only). + +# Production API baked into the JS bundle (App Store / TestFlight) +EXPO_PUBLIC_API_URL=https://beenvoice.app + +# App Store Connect API key (Users and Access → Integrations → App Store Connect API) +# Create a key with Developer role. Download the .p8 once — Apple won't show it again. +APP_STORE_CONNECT_API_KEY_ID= +APP_STORE_CONNECT_API_ISSUER_ID= +# Path to AuthKey_XXXXXX.p8 (keep outside the repo or in a secrets folder) +APP_STORE_CONNECT_API_KEY_PATH= + +# Optional: auto-increment CFBundleVersion before each archive (agvtool) +IOS_BUMP_BUILD=1 + +# Optional: skip `expo prebuild` when native project is already up to date +# IOS_SKIP_PREBUILD=1 diff --git a/apps/mobile/.vscode/extensions.json b/apps/mobile/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/apps/mobile/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/apps/mobile/.vscode/settings.json b/apps/mobile/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/apps/mobile/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md new file mode 100644 index 0000000..d658dcc --- /dev/null +++ b/apps/mobile/AGENTS.md @@ -0,0 +1,33 @@ +# beenvoice-app — agent notes + +Expo SDK **57**. Read [Expo v57 docs](https://docs.expo.dev/versions/v57.0.0/) before changing native config. + +## Read first + +- [README.md](./README.md) — setup and run +- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — routing, auth, accounts, tRPC, widgets + +## Conventions + +- **Package manager**: Bun only +- **API types**: import `AppRouter` from `beenvoice/server/api/root` (tsconfig path `../beenvoice-web/src/*`) +- **Styling**: `useAppTheme()` + `useThemedStyles()`; tokens in `lib/theme-palette.ts` +- **Forms**: `lib/form-validation.ts`; show errors only after blur/submit (`useFieldVisibility`) +- **Auth**: never remount account without migrating SecureStore session (`lib/auth-storage.ts`) +- **Widgets**: all Live Activity UI must be inside the `"widget"` function in `widgets/TimeClockActivity.tsx` +- **Metro**: port 8082; dev client required (not Expo Go) + +## Key files + +| Concern | Path | +|---------|------| +| Root providers | `app/_layout.tsx` | +| Multi-account | `contexts/AccountsContext.tsx`, `lib/accounts.ts` | +| Session migration | `lib/auth-storage.ts` | +| tRPC | `lib/trpc.tsx` | +| App lock | `lib/app-lock.ts`, `contexts/AppLockContext.tsx` | +| Time clock | `components/time-clock/TimeClockPanel.tsx` | + +## Server repo + +Sibling `../beenvoice-web` — run `bun run dev` on :3000 before mobile dev. diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/mobile/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/mobile/LICENSE b/apps/mobile/LICENSE new file mode 100644 index 0000000..30b20e3 --- /dev/null +++ b/apps/mobile/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..19f6434 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,150 @@ +# beenvoice Mobile + +Expo companion for [beenvoice-web](../beenvoice-web) — dashboard, time clock, invoices, clients, businesses, and settings. Shares the **same tRPC API** and **better-auth** sessions as the web app. + +**Architecture (dense):** [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) + +## Prerequisites + +- [Bun](https://bun.sh) 1.3+ +- beenvoice API running ([setup](../beenvoice-web/README.md)) +- Xcode + iOS Simulator (or device) for native dev build +- **Not Expo Go** — widgets, SecureStore auth, and biometrics need `expo-dev-client` + +## Setup + +```bash +cd beenvoice-app +bun install +cp .env.example .env +``` + +`.env`: + +```env +# Simulator +EXPO_PUBLIC_API_URL=http://localhost:3000 + +# Physical iPhone — Mac LAN IP +EXPO_PUBLIC_API_URL=http://192.168.1.42:3000 +``` + +Omit `EXPO_PUBLIC_API_URL` in production builds to default to `https://beenvoice.app`. + +Server must enable `@better-auth/expo` in `beenvoice/src/lib/auth.ts` with `beenvoice://` in `trustedOrigins`. + +## Run + +```bash +# Terminal 1 — API +cd ../beenvoice-web && bun run dev + +# Terminal 2 — mobile (builds native app if needed) +cd beenvoice-app && bun run ios +``` + +Metro uses port **8082** (avoids other Expo projects on 8081). + +Metro only (app already installed): + +```bash +bun run start -- --clear +``` + +Open the **beenvoice** dev build on the simulator — not Expo Go. + +### After native changes + +Icon (`assets/beenvoice.icon`), widgets, or new native modules: + +```bash +bunx expo prebuild --platform ios --clean +bun run ios +``` + +## Features + +| Area | Details | +|------|---------| +| **Auth** | Sign in, register, forgot/reset password; official or self-hosted server | +| **Multi-account** | Bitwarden-style switcher; per-account session in SecureStore | +| **Dashboard** | Revenue, pending, overdue, running timer, recent invoices | +| **Timer** | Clock in/out, client + invoice + rate; optional description (default "Clock In"); iOS Live Activity | +| **Entities** | Clients and businesses — list, create, edit | +| **Invoices** | List, filter, create, edit, status updates | +| **Settings** | Profile, accounts, theme, per-account app lock (PIN + Face ID), sign out | +| **App lock** | Per-account; locks on background return | + +## Auth & accounts (summary) + +- **Guest** auth storage: `beenvoice:guest` until first successful login +- **Per account**: `beenvoice:auth:{host::userId}` in SecureStore +- After login, `finalizeAuthenticatedAccount()` migrates session keys before activating the account (avoids double login) +- **Server picker**: Official (`beenvoice.app`) or custom URL on auth screens + +Full flow: [docs/ARCHITECTURE.md#multi-account-model](./docs/ARCHITECTURE.md#multi-account-model) + +## Deep links & Shortcuts + +| URL | Action | +|-----|--------| +| `beenvoice://reset-password?token=…` | Reset password | +| `beenvoice://timer` | Open time clock | +| `beenvoice://shortcuts/clock-in` | Clock in (last client) | +| `beenvoice://shortcuts/clock-in?title=…` | Clock in with title | +| `beenvoice://shortcuts/clock-out` | Clock out running timer | + +**iOS Shortcuts / Siri** (requires a **native dev client or TestFlight build** — not Expo Go; iOS **18+**): + +- **Clock In** — starts the timer with your last client +- **Clock Out** — stops the running timer +- **Open Time Clock** — opens the timer tab + +Shortcuts are **not pre-installed** in your Shortcuts library. To add one: + +1. Install a fresh native build (`bunx expo prebuild --platform ios && bun run ios`, or a new EAS/TestFlight build). +2. Open beenvoice once while signed in. +3. Open **Shortcuts** → **+** → **Add Action** → search **beenvoice** (or “Clock In”). +4. Choose **Clock In**, **Clock Out**, or **Open Time Clock**. +5. Pick a client once on the Timer tab before the first clock-in shortcut. + +You can also say “Hey Siri, clock in with beenvoice”. Settings → Shortcuts & Siri in the app has a setup guide and test links. + +If beenvoice actions never appear when searching in Shortcuts, the installed build predates App Intents — rebuild and reinstall. + +**Test deep links:** + +```bash +xcrun simctl openurl booted "beenvoice://shortcuts/clock-in" +xcrun simctl openurl booted "beenvoice://shortcuts/clock-out" +xcrun simctl openurl booted "beenvoice://timer" +``` + +## Project layout + +``` +app/ + _layout.tsx # Providers, auth guard + (auth)/ # sign-in, register, password flows + (app)/ # tab shell + nested stacks +components/ # UI, forms, chrome, time clock +contexts/ # Auth, Accounts, AppLock, Theme +lib/ # tRPC, auth storage, config, theming +widgets/ # iOS Live Activity (TimeClockActivity) +``` + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `PlatformConstants` / runtime not ready | Stop other Metro on 8081/8082; rebuild with `prebuild --clean` | +| Expo Go | Use `bun run ios` dev build | +| API errors on device | `EXPO_PUBLIC_API_URL` = Mac LAN IP; server `BETTER_AUTH_URL` must match | +| Live Activity empty | Rebuild iOS; widget UI must live inside `"widget"` function | +| Login twice | Server + app versions with session migration (`lib/auth-storage.ts`) | + +## Related + +- [beenvoice-web README](../beenvoice-web/README.md) +- [beenvoice-web ARCHITECTURE](../beenvoice-web/docs/ARCHITECTURE.md) +- [Workspace root README](../README.md) diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..d7665d7 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,140 @@ +{ + "expo": { + "name": "beenvoice", + "slug": "beenvoice", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "beenvoice", + "userInterfaceStyle": "automatic", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.beenvoice.app", + "buildNumber": "28", + "icon": "./assets/beenvoice.icon", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false, + "NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.", + "NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.", + "NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.", + "NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking." + } + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#D9D9D9", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.USE_BIOMETRIC", + "android.permission.USE_FINGERPRINT", + "android.permission.CAMERA" + ] + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-dev-client", + [ + "expo-build-properties", + { + "ios": { + "deploymentTarget": "18.0", + "buildReactNativeFromSource": true + } + } + ], + "expo-router", + "expo-secure-store", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#D9D9D9" + } + ], + [ + "expo-widgets", + { + "groupIdentifier": "group.com.beenvoice.app", + "bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget" + } + ], + "./plugins/withSyncWidgetVersions.js", + "./plugins/withLiveActivityBannerFrame.js", + "./plugins/withStableWidgetsChildIdentity.js", + [ + "expo-local-authentication", + { + "faceIDPermission": "Unlock beenvoice with Face ID when returning to the app." + } + ], + [ + "expo-notifications", + { + "icon": "./assets/images/icon.png", + "color": "#18181B", + "sounds": [] + } + ], + "@react-native-community/datetimepicker", + "./plugins/withAppIntents.js", + "./plugins/withAppStoreSigning.js", + "expo-sharing", + [ + "expo-image-picker", + { + "photosPermission": "beenvoice imports receipt photos for expense tracking.", + "cameraPermission": "beenvoice uses the camera to scan expense receipts." + } + ], + [ + "./plugins/withExpoMlkitOcrEnv.js", + { + "iosEngine": "auto" + } + ], + "expo-font", + "expo-image", + "expo-status-bar", + "expo-web-browser" + ], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + }, + "extra": { + "router": { + "origin": false + }, + "eas": { + "build": { + "experimental": { + "ios": { + "appExtensions": [ + { + "targetName": "ExpoWidgetsTarget", + "bundleIdentifier": "com.beenvoice.app.ExpoWidgetsTarget", + "entitlements": { + "com.apple.security.application-groups": [ + "group.com.beenvoice.app" + ] + } + } + ] + } + } + }, + "projectId": "cdc31bf6-9c8d-49cd-aa28-7f56cbffd7d2" + } + }, + "owner": "soconnor0919" + } +} diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx new file mode 100644 index 0000000..1438ab0 --- /dev/null +++ b/apps/mobile/app/(app)/_layout.tsx @@ -0,0 +1,85 @@ +import { Platform } from "react-native"; +import { NativeTabs } from "expo-router/unstable-native-tabs"; + +import { AppLockOverlay } from "@/components/AppLockOverlay"; +import { InvoiceReminderSync } from "@/components/InvoiceReminderSync"; +import { OnboardingGate } from "@/components/OnboardingGate"; +import { ShortcutHandler } from "@/components/ShortcutHandler"; +import { TimeClockLiveActivitySync } from "@/components/time-clock/TimeClockLiveActivitySync"; +import { useAppTheme } from "@/contexts/ThemeContext"; +import { AppLockProvider } from "@/contexts/AppLockContext"; + +export default function AppLayout() { + const { colors, isDark } = useAppTheme(); + + const tintColor = colors.primary; + const labelColor = colors.mutedForeground; + const tabContentStyle = { backgroundColor: colors.background }; + const tabBarBlur = + Platform.OS === "ios" + ? isDark + ? "systemChromeMaterialDark" + : "systemChromeMaterialLight" + : undefined; + + return ( + + + + + Home + + + + + Timer + + + + + Entities + + + + + Invoices + + + + + More + + + + + + + + + ); +} diff --git a/apps/mobile/app/(app)/entities/_layout.tsx b/apps/mobile/app/(app)/entities/_layout.tsx new file mode 100644 index 0000000..245cc35 --- /dev/null +++ b/apps/mobile/app/(app)/entities/_layout.tsx @@ -0,0 +1,76 @@ +import { Stack } from "expo-router"; + +import { fonts } from "@/constants/theme"; +import { useAppTheme } from "@/contexts/ThemeContext"; + +export default function EntitiesLayout() { + const { colors } = useAppTheme(); + + return ( + + + + + + + + + + ); +} diff --git a/apps/mobile/app/(app)/entities/businesses/[id].tsx b/apps/mobile/app/(app)/entities/businesses/[id].tsx new file mode 100644 index 0000000..04f0755 --- /dev/null +++ b/apps/mobile/app/(app)/entities/businesses/[id].tsx @@ -0,0 +1,186 @@ +import { router, Stack, useLocalSearchParams } from "expo-router"; +import { Alert, ScrollView, StyleSheet, Text, View } from "react-native"; + +import { AppBackground } from "@/components/AppBackground"; +import { LoadingScreen } from "@/components/LoadingScreen"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { fonts, spacing } from "@/constants/theme"; +import { useAppTheme } from "@/contexts/ThemeContext"; +import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; +import type { ThemeColors } from "@/lib/theme-palette"; +import { useThemedStyles } from "@/lib/use-themed-styles"; +import { api } from "@/lib/trpc"; + +export default function BusinessDetailScreen() { + const { colors } = useAppTheme(); + const styles = useThemedStyles(createBusinessDetailStyles); + const { id } = useLocalSearchParams<{ id: string }>(); + const scrollPadding = useTabBarScrollPadding(); + const utils = api.useUtils(); + + const businessQuery = api.businesses.getById.useQuery( + { id: id ?? "" }, + { enabled: Boolean(id) }, + ); + + const setDefault = api.businesses.setDefault.useMutation({ + onSuccess: () => { + void utils.businesses.getAll.invalidate(); + if (id) void utils.businesses.getById.invalidate({ id }); + Alert.alert("Default updated", "This business is now your default."); + }, + onError: (err) => Alert.alert("Could not set default", err.message), + }); + + if (!id) { + return ; + } + + if (businessQuery.isLoading) { + return ; + } + + const business = businessQuery.data; + if (!business) { + return ; + } + + return ( + + + + + + {business.name} + {business.isDefault ? Default : null} + + {business.nickname ? {business.nickname} : null} + {business.email ? {business.email} : null} + {business.phone ? {business.phone} : null} + {business.website ? {business.website} : null} + + + + {business.taxId ? ( + + ) : null} + + + + {(business.addressLine1 || business.city || business.state) && ( + + {business.addressLine1 ? ( + {business.addressLine1} + ) : null} + {business.addressLine2 ? ( + {business.addressLine2} + ) : null} + {(business.city || business.state || business.postalCode) && ( + + {[business.city, business.state, business.postalCode].filter(Boolean).join(", ")} + + )} + {business.country ? {business.country} : null} + + )} + + +