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>
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "beenvoice-app",
|
"name": "beenvoice",
|
||||||
"slug": "beenvoice-app",
|
"slug": "beenvoice",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "beenvoiceapp",
|
"scheme": "beenvoice",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"ios": {
|
"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": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#D9D9D9",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@@ -25,18 +30,38 @@
|
|||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"expo-dev-client",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"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": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppLockProvider>
|
||||||
|
<NativeTabs
|
||||||
|
tintColor={tintColor}
|
||||||
|
iconColor={{
|
||||||
|
default: labelColor,
|
||||||
|
selected: tintColor,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: labelColor }}
|
||||||
|
>
|
||||||
|
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
|
||||||
|
md="grid_view"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="timer" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "timer", selected: "timer" }}
|
||||||
|
md="timer"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "doc.text", selected: "doc.text.fill" }}
|
||||||
|
md="description"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="settings" disableAutomaticContentInsets contentStyle={tabContentStyle}>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: "gearshape", selected: "gearshape.fill" }}
|
||||||
|
md="settings"
|
||||||
|
/>
|
||||||
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
<AppLockOverlay />
|
||||||
|
</AppLockProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <LoadingScreen message="Loading dashboard…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<Screen>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load dashboard</Text>
|
||||||
|
<Text style={styles.errorText}>{statsQuery.error.message}</Text>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
if (!stats) {
|
||||||
|
return <LoadingScreen message="Loading dashboard…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Overview" subtitle="Your invoicing at a glance" />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={statsQuery.isRefetching || runningQuery.isRefetching}
|
||||||
|
onRefresh={() => {
|
||||||
|
void statsQuery.refetch();
|
||||||
|
void runningQuery.refetch();
|
||||||
|
}}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Pressable onPress={() => router.push("/(app)/timer")}>
|
||||||
|
<GlassSurface style={styles.runningGlass}>
|
||||||
|
<View style={styles.runningRow}>
|
||||||
|
<View style={styles.runningDot} />
|
||||||
|
<View style={styles.runningMeta}>
|
||||||
|
<Text style={styles.runningTitle}>
|
||||||
|
{running.description || "Timer running"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.runningSub}>
|
||||||
|
{running.client?.name ?? "No client"}
|
||||||
|
{running.invoice
|
||||||
|
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.runningTime}>
|
||||||
|
{formatElapsedHoursMinutes(runningElapsed)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stats.overdueCount > 0 ? (
|
||||||
|
<GlassSurface style={styles.alertGlass}>
|
||||||
|
<View style={styles.alertBanner}>
|
||||||
|
<Text style={styles.alertTitle}>
|
||||||
|
{stats.overdueCount} overdue {stats.overdueCount === 1 ? "invoice" : "invoices"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.alertText}>
|
||||||
|
Follow up on outstanding payments from the Invoices tab.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.quickActions}>
|
||||||
|
<Button title="Start timer" onPress={() => router.push("/(app)/timer")} />
|
||||||
|
<Button
|
||||||
|
title="View invoices"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push("/(app)/invoices")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<StatCard label="Total revenue" value={formatCurrency(stats.totalRevenue)} />
|
||||||
|
<StatCard label="Pending" value={formatCurrency(stats.pendingAmount)} />
|
||||||
|
<StatCard
|
||||||
|
label="Overdue"
|
||||||
|
value={String(stats.overdueCount)}
|
||||||
|
hint={stats.overdueCount === 1 ? "invoice" : "invoices"}
|
||||||
|
/>
|
||||||
|
<StatCard label="Clients" value={String(stats.totalClients)} hint={revenueChange} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card title="Revenue (6 months)">
|
||||||
|
<View style={styles.chart}>
|
||||||
|
{stats.revenueChartData.map((point) => (
|
||||||
|
<View key={point.month} style={styles.chartColumn}>
|
||||||
|
<View style={styles.chartBarTrack}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.chartBar,
|
||||||
|
{ height: `${Math.max(8, (point.revenue / maxRevenue) * 100)}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.chartLabel}>{point.monthLabel}</Text>
|
||||||
|
<Text style={styles.chartValue}>
|
||||||
|
{point.revenue > 0 ? formatCurrency(point.revenue) : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Recent invoices">
|
||||||
|
{stats.recentInvoices.length === 0 ? (
|
||||||
|
<Text style={styles.empty}>No invoices yet. Create one on the web app.</Text>
|
||||||
|
) : (
|
||||||
|
stats.recentInvoices.map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={invoice.id}
|
||||||
|
style={styles.invoiceRow}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.invoiceMeta}>
|
||||||
|
<Text style={styles.invoiceTitle}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.invoiceClient}>
|
||||||
|
{invoice.client?.name ?? "Client"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.invoiceDate}>{formatDate(invoice.issueDate)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.invoiceRight}>
|
||||||
|
<Text style={styles.invoiceAmount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDashboardStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
runningGlass: {
|
||||||
|
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "#BBF7D0",
|
||||||
|
},
|
||||||
|
runningRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
runningDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
runningMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
runningTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
runningSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
runningTime: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.success,
|
||||||
|
},
|
||||||
|
alertBanner: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
alertGlass: {
|
||||||
|
borderColor: isDark ? "rgba(251, 191, 36, 0.4)" : "#FDE68A",
|
||||||
|
},
|
||||||
|
alertTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
alertText: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
quickActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 140,
|
||||||
|
},
|
||||||
|
chartColumn: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
chartBarTrack: {
|
||||||
|
width: "100%",
|
||||||
|
height: 80,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: "70%",
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
},
|
||||||
|
chartLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
chartValue: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
invoiceRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
invoiceMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
invoiceTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
invoiceClient: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
invoiceDate: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
invoiceRight: {
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
invoiceAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, 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, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function InvoiceDetailScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createInvoiceDetailStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendInvoice = api.email.sendInvoice.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Alert.alert("Invoice sent", data.message);
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Could not send invoice", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.error || !invoiceQuery.data) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load invoice</Text>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{invoiceQuery.error?.message ?? "Invoice not found"}
|
||||||
|
</Text>
|
||||||
|
<Button title="Go back" variant="secondary" onPress={() => router.back()} />
|
||||||
|
</View>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
|
const clientEmail = invoice.client?.email?.trim() ?? "";
|
||||||
|
|
||||||
|
function promptSendInvoice() {
|
||||||
|
if (!clientEmail) {
|
||||||
|
Alert.alert(
|
||||||
|
"No client email",
|
||||||
|
"Add an email address to this client on the web app before sending invoices.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
status === "draft" ? "Send invoice" : "Resend invoice",
|
||||||
|
`Email this invoice to ${clientEmail}?`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Send",
|
||||||
|
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptStatusChange(current: InvoiceStatus) {
|
||||||
|
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||||
|
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||||
|
if (current !== "sent" && current !== "overdue") {
|
||||||
|
options.push({ label: "Mark as sent", status: "sent" });
|
||||||
|
}
|
||||||
|
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
Alert.alert("Update status", "Choose a new status", [
|
||||||
|
...options.map((option) => ({
|
||||||
|
text: option.label,
|
||||||
|
onPress: () => updateStatus.mutate({ id: invoice.id, status: option.status }),
|
||||||
|
})),
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.headerMeta}>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.total}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Details">
|
||||||
|
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
|
||||||
|
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
|
||||||
|
<DetailRow label="Currency" value={invoice.currency} />
|
||||||
|
{invoice.taxRate > 0 ? (
|
||||||
|
<DetailRow label="Tax rate" value={`${invoice.taxRate}%`} />
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Line items">
|
||||||
|
{invoice.items.map((item) => (
|
||||||
|
<View key={item.id} style={styles.lineItem}>
|
||||||
|
<View style={styles.lineMeta}>
|
||||||
|
<Text style={styles.lineDescription}>{item.description}</Text>
|
||||||
|
<Text style={styles.lineSub}>
|
||||||
|
{formatDate(item.date)} · {item.hours}h ×{" "}
|
||||||
|
{formatCurrency(item.rate, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.lineAmount}>
|
||||||
|
{formatCurrency(item.amount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.totals}>
|
||||||
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal, invoice.currency)} />
|
||||||
|
{invoice.taxRate > 0 ? (
|
||||||
|
<TotalRow
|
||||||
|
label={`Tax (${invoice.taxRate}%)`}
|
||||||
|
value={formatCurrency(taxAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TotalRow
|
||||||
|
label="Total"
|
||||||
|
value={formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
bold
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{invoice.notes ? (
|
||||||
|
<Card title="Notes">
|
||||||
|
<Text style={styles.notes}>{invoice.notes}</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{status !== "paid" ? (
|
||||||
|
<Button
|
||||||
|
title={status === "draft" ? "Send invoice" : "Resend invoice"}
|
||||||
|
onPress={promptSendInvoice}
|
||||||
|
loading={sendInvoice.isPending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Edit invoice"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Update status"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => promptStatusChange(status)}
|
||||||
|
loading={updateStatus.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Track time to this invoice"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={detailStyles.row}>
|
||||||
|
<Text style={[detailStyles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[detailStyles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={detailStyles.totalRow}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
detailStyles.totalLabel,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
bold && detailStyles.totalBold,
|
||||||
|
bold && { color: colors.foreground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
detailStyles.totalValue,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
bold && detailStyles.totalBold,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
totalBold: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
headerMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 26,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
fontSize: 28,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
lineItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
lineMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
lineDescription: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
lineSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
lineAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export default function InvoicesLayout() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
headerStyle: { backgroundColor: colors.cardGlass },
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerTintColor: colors.foreground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
statusBarTranslucent: true,
|
||||||
|
contentStyle: { flex: 1, backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="[id]" options={{ title: "Invoice" }} />
|
||||||
|
<Stack.Screen name="edit/[id]" options={{ title: "Edit" }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { LineItemEditor, type EditableLineItem } from "@/components/invoices/LineItemEditor";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useNativeTabBarHeight, useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export default function InvoiceEditScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createInvoiceEditStyles);
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const tabBarHeight = useNativeTabBarHeight();
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const invoiceQuery = api.invoices.getById.useQuery(
|
||||||
|
{ id: id ?? "" },
|
||||||
|
{ enabled: Boolean(id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [dueDate, setDueDate] = useState(() => new Date());
|
||||||
|
const [items, setItems] = useState<EditableLineItem[]>([]);
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [footerHeight, setFooterHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
if (!invoice) return;
|
||||||
|
setNotes(invoice.notes ?? "");
|
||||||
|
setDueDate(new Date(invoice.dueDate));
|
||||||
|
setItems(
|
||||||
|
invoice.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
date: new Date(item.date),
|
||||||
|
description: item.description,
|
||||||
|
hours: String(item.hours),
|
||||||
|
rate: String(item.rate),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setExpandedIndex(null);
|
||||||
|
}, [invoiceQuery.data]);
|
||||||
|
|
||||||
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.invoices.getById.invalidate({ id: id ?? "" });
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
|
Alert.alert("Saved", "Invoice updated", [
|
||||||
|
{ text: "OK", onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err) => setError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoice = invoiceQuery.data;
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce((sum, item) => {
|
||||||
|
const hours = Number(item.hours) || 0;
|
||||||
|
const rate = Number(item.rate) || 0;
|
||||||
|
return sum + hours * rate;
|
||||||
|
}, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const taxRate = invoice?.taxRate ?? 0;
|
||||||
|
const taxAmount = subtotal * (taxRate / 100);
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
const currency = invoice?.currency ?? "USD";
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return <LoadingScreen message="Invalid invoice" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoice…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return <LoadingScreen message="Invoice not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<EditableLineItem>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
const nextIndex = items.length;
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: "1",
|
||||||
|
rate: prev[prev.length - 1]?.rate ?? "0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setExpandedIndex(nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
if (items.length <= 1) {
|
||||||
|
Alert.alert("Cannot remove", "An invoice needs at least one line item.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setExpandedIndex((current) => {
|
||||||
|
if (current === null) return null;
|
||||||
|
if (current === index) return null;
|
||||||
|
return current > index ? current - 1 : current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const parsedItems: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const hours = Number(item.hours);
|
||||||
|
const rate = Number(item.rate);
|
||||||
|
if (!item.description.trim()) {
|
||||||
|
setError("Each line needs a description");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(hours) || hours < 0) {
|
||||||
|
setError("Hours must be a valid number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(rate) || rate < 0) {
|
||||||
|
setError("Rate must be a valid number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parsedItems.push({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description.trim(),
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvoice.mutate({
|
||||||
|
id,
|
||||||
|
notes,
|
||||||
|
dueDate,
|
||||||
|
items: parsedItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.container,
|
||||||
|
{ paddingBottom: scrollPadding + footerHeight },
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding + footerHeight }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientName}>{invoice.client?.name ?? "Client"}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={setDueDate} />
|
||||||
|
<Input
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Optional notes for the client"
|
||||||
|
multiline
|
||||||
|
style={styles.notesInput}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Line items">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<LineItemEditor
|
||||||
|
key={item.id ?? `new-${index}`}
|
||||||
|
item={item}
|
||||||
|
currency={currency}
|
||||||
|
expanded={expandedIndex === index}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedIndex((current) => (current === index ? null : index))
|
||||||
|
}
|
||||||
|
onChange={(patch) => updateItem(index, patch)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pressable accessibilityRole="button" onPress={addItem} style={styles.addLine}>
|
||||||
|
<Text style={styles.addLineText}>+ Add line</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.totals}>
|
||||||
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal, currency)} />
|
||||||
|
{taxRate > 0 ? (
|
||||||
|
<TotalRow
|
||||||
|
label={`Tax (${taxRate}%)`}
|
||||||
|
value={formatCurrency(taxAmount, currency)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TotalRow label="Total" value={formatCurrency(total, currency)} bold />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View
|
||||||
|
onLayout={(event) => setFooterHeight(event.nativeEvent.layout.height)}
|
||||||
|
style={[
|
||||||
|
styles.footer,
|
||||||
|
{
|
||||||
|
bottom: tabBarHeight,
|
||||||
|
paddingBottom: Math.max(insets.bottom, spacing.sm),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button title="Save changes" loading={updateInvoice.isPending} onPress={handleSave} />
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={totalStyles.row}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
totalStyles.label,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
bold && totalStyles.bold,
|
||||||
|
bold && { color: colors.foreground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
totalStyles.value,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
bold && totalStyles.bold,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoiceEditStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
notesInput: {
|
||||||
|
minHeight: 72,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
addLine: {
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
addLineText: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
import { fonts, 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, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const filters: Array<{ label: string; value?: InvoiceStatus | "all" }> = [
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Sent", value: "sent" },
|
||||||
|
{ label: "Paid", value: "paid" },
|
||||||
|
{ label: "Overdue", value: "overdue" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InvoicesScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createInvoicesStyles);
|
||||||
|
const [filter, setFilter] = useState<(typeof filters)[number]["value"]>("all");
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const invoicesQuery = api.invoices.getAll.useQuery();
|
||||||
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invoices.getAll.invalidate();
|
||||||
|
utils.dashboard.getStats.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => Alert.alert("Update failed", err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invoicesQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading invoices…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoicesQuery.error) {
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorTitle}>Could not load invoices</Text>
|
||||||
|
<Text style={styles.errorText}>{invoicesQuery.error.message}</Text>
|
||||||
|
</View>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = (invoicesQuery.data ?? []).filter((invoice) => {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
return getInvoiceStatus(invoice) === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
function promptStatusChange(invoiceId: string, current: InvoiceStatus) {
|
||||||
|
const options: Array<{ label: string; status: "draft" | "sent" | "paid" }> = [];
|
||||||
|
|
||||||
|
if (current !== "draft") options.push({ label: "Mark as draft", status: "draft" });
|
||||||
|
if (current !== "sent" && current !== "overdue") {
|
||||||
|
options.push({ label: "Mark as sent", status: "sent" });
|
||||||
|
}
|
||||||
|
if (current !== "paid") options.push({ label: "Mark as paid", status: "paid" });
|
||||||
|
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
Alert.alert("Update status", "Choose a new status", [
|
||||||
|
...options.map((option) => ({
|
||||||
|
text: option.label,
|
||||||
|
onPress: () => {
|
||||||
|
updateStatus.mutate({ id: invoiceId, status: option.status });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Invoices" subtitle="Review status, amounts, and due dates" />
|
||||||
|
}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={invoicesQuery.isRefetching}
|
||||||
|
onRefresh={() => invoicesQuery.refetch()}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.filterScroll}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
{filters.map((item) => (
|
||||||
|
<FilterChip
|
||||||
|
key={item.label}
|
||||||
|
label={item.label}
|
||||||
|
active={filter === item.value}
|
||||||
|
onPress={() => setFilter(item.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyTitle}>No invoices found</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Create invoices on the web app, then view and edit them here.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
invoices.map((invoice) => {
|
||||||
|
const status = getInvoiceStatus(invoice);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={invoice.id}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${invoice.id}`)}
|
||||||
|
onLongPress={() => promptStatusChange(invoice.id, status)}
|
||||||
|
>
|
||||||
|
<GlassSurface style={styles.card}>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.cardTop}>
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.clientName}>
|
||||||
|
{invoice.client?.name ?? "Client"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.amount}>
|
||||||
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardBottom}>
|
||||||
|
<Text style={styles.date}>Due {formatDate(invoice.dueDate)}</Text>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInvoicesStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
filterScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingRight: spacing.md,
|
||||||
|
},
|
||||||
|
card: {},
|
||||||
|
cardInner: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cardTop: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cardMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
invoiceNumber: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
clientName: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
cardBottom: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TabScrollView } from "@/components/TabScrollView";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { InstanceUrlField } from "@/components/InstanceUrlField";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { PinPrompt } from "@/components/PinPrompt";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppLock } from "@/contexts/AppLockContext";
|
||||||
|
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||||||
|
{ value: "system", label: "System" },
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
activeAccount,
|
||||||
|
activeAccountId,
|
||||||
|
apiUrl,
|
||||||
|
switchAccount,
|
||||||
|
removeAccount,
|
||||||
|
clearActiveAccount,
|
||||||
|
} = useAccounts();
|
||||||
|
const { colors, colorMode, setColorMode } = useAppTheme();
|
||||||
|
const {
|
||||||
|
enabled: lockEnabled,
|
||||||
|
biometricEnabled,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
enableLock,
|
||||||
|
disableLock,
|
||||||
|
changePin,
|
||||||
|
setUseBiometric,
|
||||||
|
lock,
|
||||||
|
} = useAppLock();
|
||||||
|
const profileQuery = api.settings.getProfile.useQuery();
|
||||||
|
|
||||||
|
const [pinPrompt, setPinPrompt] = useState<
|
||||||
|
| { mode: "create" }
|
||||||
|
| { mode: "confirm-disable" }
|
||||||
|
| { mode: "change-current" }
|
||||||
|
| { mode: "change-next" }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
const [pendingPin, setPendingPin] = useState("");
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await authClient.signOut();
|
||||||
|
await clearActiveAccount();
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSignOut() {
|
||||||
|
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{ text: "Sign out", style: "destructive", onPress: handleSignOut },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemoveAccount(accountId: string, label: string) {
|
||||||
|
Alert.alert("Remove account", `Remove ${label} from this device?`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => void removeAccount(accountId),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmInstanceChange() {
|
||||||
|
Alert.alert(
|
||||||
|
"Server updated",
|
||||||
|
"You may need to sign in again if you switched to a different instance.",
|
||||||
|
[{ text: "OK" }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLockToggle(next: boolean) {
|
||||||
|
if (next) {
|
||||||
|
setPinPrompt({ mode: "create" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPinPrompt({ mode: "confirm-disable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangePin() {
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt({ mode: "change-current" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBiometricToggle(next: boolean) {
|
||||||
|
void setUseBiometric(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePinPromptSubmit(pin: string) {
|
||||||
|
if (pinPrompt?.mode === "create") {
|
||||||
|
try {
|
||||||
|
await enableLock(pin);
|
||||||
|
setPinPrompt(null);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Could not enable lock", err instanceof Error ? err.message : "Try again");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "confirm-disable") {
|
||||||
|
const success = await disableLock(pin);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert("Incorrect PIN", "Could not disable app lock.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPinPrompt(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "change-current") {
|
||||||
|
setPendingPin(pin);
|
||||||
|
setPinPrompt({ mode: "change-next" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinPrompt?.mode === "change-next") {
|
||||||
|
const success = await changePin(pendingPin, pin);
|
||||||
|
if (!success) {
|
||||||
|
Alert.alert("Could not change PIN", "Check your current PIN and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt(null);
|
||||||
|
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading profile…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = profileQuery.data;
|
||||||
|
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<PinPrompt
|
||||||
|
visible={pinPrompt !== null}
|
||||||
|
title={
|
||||||
|
pinPrompt?.mode === "create"
|
||||||
|
? "Create PIN"
|
||||||
|
: pinPrompt?.mode === "confirm-disable"
|
||||||
|
? "Disable app lock"
|
||||||
|
: pinPrompt?.mode === "change-current"
|
||||||
|
? "Current PIN"
|
||||||
|
: "New PIN"
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||||||
|
? "Choose a 4–6 digit PIN."
|
||||||
|
: pinPrompt?.mode === "confirm-disable"
|
||||||
|
? "Enter your PIN to turn off app lock."
|
||||||
|
: "Enter your current PIN."
|
||||||
|
}
|
||||||
|
confirmLabel={
|
||||||
|
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next" ? "Save" : "Continue"
|
||||||
|
}
|
||||||
|
requireConfirmation={pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingPin("");
|
||||||
|
setPinPrompt(null);
|
||||||
|
}}
|
||||||
|
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
|
||||||
|
/>
|
||||||
|
<TabScrollView
|
||||||
|
header={
|
||||||
|
<PageHeader title="Settings" subtitle="Account and app preferences" />
|
||||||
|
}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card title="Account">
|
||||||
|
<Text style={[styles.name, { color: colors.foreground }]}>
|
||||||
|
{profile?.name ?? session?.user.name ?? "User"}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.email, { color: colors.mutedForeground }]}>
|
||||||
|
{profile?.email ?? session?.user.email}
|
||||||
|
</Text>
|
||||||
|
{profile?.role ? (
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Role: {profile.role}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Accounts">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isActive = account.id === activeAccountId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={account.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void switchAccount(account.id)}
|
||||||
|
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.accountRow,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: isActive ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.accountMeta}>
|
||||||
|
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||||||
|
{account.name || account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{account.email}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||||||
|
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isActive ? (
|
||||||
|
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
title="Add another account"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
await clearActiveAccount();
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{accounts.length > 1 ? (
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Long-press an account to remove it from this device.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Security">
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingCopy}>
|
||||||
|
<Text style={[styles.settingTitle, { color: colors.foreground }]}>App lock</Text>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Require a PIN when reopening the app
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={lockEnabled}
|
||||||
|
onValueChange={handleLockToggle}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{lockEnabled && biometricAvailable ? (
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingCopy}>
|
||||||
|
<Text style={[styles.settingTitle, { color: colors.foreground }]}>
|
||||||
|
{biometricLabel}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||||||
|
Unlock with {biometricLabel.toLowerCase()} when available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={biometricEnabled}
|
||||||
|
onValueChange={handleBiometricToggle}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lockEnabled ? (
|
||||||
|
<>
|
||||||
|
<Button title="Change PIN" variant="secondary" onPress={handleChangePin} />
|
||||||
|
<Button title="Lock now" variant="secondary" onPress={lock} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Appearance">
|
||||||
|
<View style={styles.themeRow}>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const selected = colorMode === option.value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void setColorMode(option.value)}
|
||||||
|
style={[
|
||||||
|
styles.themeChip,
|
||||||
|
{
|
||||||
|
borderColor: selected ? colors.primary : colors.border,
|
||||||
|
backgroundColor: selected ? colors.muted : "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.themeChipLabel,
|
||||||
|
{ color: selected ? colors.foreground : colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Server instance">
|
||||||
|
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||||||
|
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
||||||
|
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="App">
|
||||||
|
<View style={styles.appRow}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
|
||||||
|
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.appRow}>
|
||||||
|
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
|
||||||
|
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||||||
|
{Constants.platform?.ios ? "iOS" : "Other"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
|
||||||
|
</View>
|
||||||
|
</TabScrollView>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
name: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
currentServer: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
},
|
||||||
|
appRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
appValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
accountRow: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: spacing.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
accountMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
accountSub: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
activeBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
themeRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
themeChip: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
minHeight: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
themeChipLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
lineHeight: 18,
|
||||||
|
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
settingCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { TabPage } from "@/components/TabPage";
|
||||||
|
import { TimeClockPanel } from "@/components/time-clock/TimeClockPanel";
|
||||||
|
|
||||||
|
export default function TimerScreen() {
|
||||||
|
const { clientId, invoiceId } = useLocalSearchParams<{
|
||||||
|
clientId?: string;
|
||||||
|
invoiceId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBackground>
|
||||||
|
<TabPage>
|
||||||
|
<TimeClockPanel
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title="Time clock"
|
||||||
|
subtitle="Track billable hours and link them to invoices"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
defaultClientId={clientId}
|
||||||
|
defaultInvoiceId={invoiceId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</TabPage>
|
||||||
|
</AppBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
||||||
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { requestPasswordReset } from "@/lib/auth-api";
|
||||||
|
|
||||||
|
export default function ForgotPasswordScreen() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await requestPasswordReset(email.trim());
|
||||||
|
setMessage(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Request failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={[styles.back, { color: colors.mutedForeground }]}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Logo size="md" />
|
||||||
|
<HeadingText style={styles.title}>Reset password</HeadingText>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Enter your email and we'll send reset instructions if an account exists.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
{message ? (
|
||||||
|
<Text style={[styles.success, { color: colors.foreground }]}>{message}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button title="Send reset link" loading={loading} onPress={handleSubmit} />
|
||||||
|
<Button
|
||||||
|
title="Have a reset token?"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => router.push("/(auth)/reset-password")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
<CollapsibleServerField />
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: { gap: spacing.lg },
|
||||||
|
header: { gap: spacing.sm },
|
||||||
|
title: { fontSize: 28 },
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
form: { gap: spacing.md },
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthIndex() {
|
||||||
|
return <Redirect href="/(auth)/sign-in" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Link, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
||||||
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient } from "@/contexts/AuthContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { registerAccount } from "@/lib/auth-api";
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { apiUrl, registerAccount: saveAccount } = useAccounts();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registerAccount({
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error: signInError } = await authClient.signIn.email({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
router.replace("/(auth)/sign-in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
const user = session.data?.user;
|
||||||
|
if (user) {
|
||||||
|
await saveAccount({
|
||||||
|
instanceUrl: apiUrl,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/(app)");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Logo size="lg" />
|
||||||
|
<HeadingText style={styles.title}>Create your account</HeadingText>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Get started today
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.half}>
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
autoComplete="given-name"
|
||||||
|
placeholder="Jane"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.half}>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
autoComplete="family-name"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button title="Create Account" loading={loading} onPress={handleRegister} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/(auth)/sign-in" style={[styles.link, { color: colors.foreground }]}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
<CollapsibleServerField />
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
header: { gap: spacing.sm },
|
||||||
|
title: { fontSize: 24, marginTop: spacing.sm },
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
form: { gap: spacing.md },
|
||||||
|
row: { flexDirection: "row", gap: spacing.md },
|
||||||
|
half: { flex: 1 },
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { HeadingText } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { resetPassword } from "@/lib/auth-api";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
|
||||||
|
export default function ResetPasswordScreen() {
|
||||||
|
const styles = useThemedStyles(createResetPasswordStyles);
|
||||||
|
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof tokenParam === "string" && tokenParam.length > 0) {
|
||||||
|
setToken(tokenParam);
|
||||||
|
}
|
||||||
|
}, [tokenParam]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resetPassword(token.trim(), password);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Reset failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.container}>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={styles.back}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<HeadingText style={styles.title}>Set new password</HeadingText>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Paste the reset token from your email, or open the link on this device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<View style={styles.successBox}>
|
||||||
|
<Text style={styles.successTitle}>Password updated</Text>
|
||||||
|
<Text style={styles.successText}>
|
||||||
|
You can now sign in with your new password.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Go to sign in"
|
||||||
|
onPress={() => router.replace("/(auth)/sign-in")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Reset token"
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={token}
|
||||||
|
onChangeText={setToken}
|
||||||
|
placeholder="Paste token from email"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New password"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm password"
|
||||||
|
secureTextEntry
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
placeholder="Repeat password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<Button title="Update password" loading={loading} onPress={handleSubmit} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createResetPasswordStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
safe: { flex: 1 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: { gap: spacing.lg },
|
||||||
|
header: { gap: spacing.sm },
|
||||||
|
title: { fontSize: 28 },
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
form: { gap: spacing.md },
|
||||||
|
error: {
|
||||||
|
color: colors.destructive,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
successBox: {
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
successTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
successText: {
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Link, router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { AuthBackground } from "@/components/AppBackground";
|
||||||
|
import { CollapsibleServerField } from "@/components/CollapsibleServerField";
|
||||||
|
import { HeadingText, Logo } from "@/components/Logo";
|
||||||
|
import { FullScreen } from "@/components/Screen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAuthClient } from "@/contexts/AuthContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export default function SignInScreen() {
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { apiUrl, registerAccount } = useAccounts();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSignIn() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error: signInError } = await authClient.signIn.email({ email: email.trim(), password });
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
setLoading(false);
|
||||||
|
const message = signInError.message ?? "";
|
||||||
|
if (message.toLowerCase().includes("internal") || message.includes("500")) {
|
||||||
|
setError("Server error — is the API running with Postgres? Check beenvoice dev + docker.");
|
||||||
|
} else {
|
||||||
|
setError(message || "Invalid email or password");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
const user = session.data?.user;
|
||||||
|
if (user) {
|
||||||
|
await registerAccount({
|
||||||
|
instanceUrl: apiUrl,
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
router.replace("/(app)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthBackground>
|
||||||
|
<FullScreen style={styles.safe}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Logo size="lg" />
|
||||||
|
<HeadingText style={styles.title}>Welcome back</HeadingText>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Sign in to manage invoices on the go
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
secureTextEntry
|
||||||
|
autoComplete="password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
|
||||||
|
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
|
||||||
|
Forgot password?
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button title="Sign In" loading={loading} onPress={handleSignIn} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
<CollapsibleServerField />
|
||||||
|
</FullScreen>
|
||||||
|
</AuthBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
flex: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
forgot: {
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { SymbolView } from 'expo-symbols';
|
|
||||||
import { Link, Tabs } from 'expo-router';
|
|
||||||
import { Platform, Pressable } from 'react-native';
|
|
||||||
|
|
||||||
import Colors from '@/constants/Colors';
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
|
||||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
|
||||||
// Disable the static render of the header on web
|
|
||||||
// to prevent a hydration error in React Navigation v6.
|
|
||||||
headerShown: useClientOnlyValue(false, true),
|
|
||||||
}}>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: 'Tab One',
|
|
||||||
tabBarIcon: ({ color }) => (
|
|
||||||
<SymbolView
|
|
||||||
name={{
|
|
||||||
ios: 'chevron.left.forwardslash.chevron.right',
|
|
||||||
android: 'code',
|
|
||||||
web: 'code',
|
|
||||||
}}
|
|
||||||
tintColor={color}
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
|
||||||
<Link href="/modal" asChild>
|
|
||||||
<Pressable style={{ marginRight: 15 }}>
|
|
||||||
{({ pressed }) => (
|
|
||||||
<SymbolView
|
|
||||||
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
|
|
||||||
size={25}
|
|
||||||
tintColor={Colors[colorScheme].text}
|
|
||||||
style={{ opacity: pressed ? 0.5 : 1 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="two"
|
|
||||||
options={{
|
|
||||||
title: 'Tab Two',
|
|
||||||
tabBarIcon: ({ color }) => (
|
|
||||||
<SymbolView
|
|
||||||
name={{
|
|
||||||
ios: 'chevron.left.forwardslash.chevron.right',
|
|
||||||
android: 'code',
|
|
||||||
web: 'code',
|
|
||||||
}}
|
|
||||||
tintColor={color}
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
|
||||||
import { Text, View } from '@/components/Themed';
|
|
||||||
|
|
||||||
export default function TabOneScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab One</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/index.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
|
||||||
import { Text, View } from '@/components/Themed';
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Tab Two</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/(tabs)/two.tsx" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,30 +1,71 @@
|
|||||||
import { useFonts } from 'expo-font';
|
import { Stack } from "expo-router";
|
||||||
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
|
import {
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
Inter_400Regular,
|
||||||
import { useEffect } from 'react';
|
Inter_500Medium,
|
||||||
import 'react-native-reanimated';
|
Inter_600SemiBold,
|
||||||
|
Inter_700Bold,
|
||||||
|
} from "@expo-google-fonts/inter";
|
||||||
|
import {
|
||||||
|
PlayfairDisplay_600SemiBold,
|
||||||
|
PlayfairDisplay_700Bold,
|
||||||
|
} from "@expo-google-fonts/playfair-display";
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { AccountsProvider, useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { AuthProvider, useSession } from "@/contexts/AuthContext";
|
||||||
|
import { ThemeProvider, useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { TRPCProvider } from "@/lib/trpc";
|
||||||
|
|
||||||
export {
|
export { ErrorBoundary } from "expo-router";
|
||||||
// Catch any errors thrown by the Layout component.
|
|
||||||
ErrorBoundary,
|
|
||||||
} from 'expo-router';
|
|
||||||
|
|
||||||
export const unstable_settings = {
|
|
||||||
// Ensure that reloading on `/modal` keeps a back button present.
|
|
||||||
initialRouteName: '(tabs)',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
function AppServices({ children }: { children: ReactNode }) {
|
||||||
|
const { apiUrl, authStoragePrefix, activeAccountId } = useAccounts();
|
||||||
|
const remountKey = `${activeAccountId ?? "guest"}:${apiUrl}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider apiUrl={apiUrl} storagePrefix={authStoragePrefix} key={remountKey}>
|
||||||
|
<TRPCProvider apiUrl={apiUrl} key={remountKey}>
|
||||||
|
{children}
|
||||||
|
</TRPCProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemedChrome({ children }: { children: ReactNode }) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "transparent" }}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={{ flex: 1, zIndex: 1 }}>
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded, error] = useFonts({
|
const [loaded, error] = useFonts({
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
|
Inter_400Regular,
|
||||||
|
Inter_500Medium,
|
||||||
|
Inter_600SemiBold,
|
||||||
|
Inter_700Bold,
|
||||||
|
PlayfairDisplay_600SemiBold,
|
||||||
|
PlayfairDisplay_700Bold,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
@@ -39,18 +80,43 @@ export default function RootLayout() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RootLayoutNav />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootLayoutNav() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<SafeAreaProvider>
|
||||||
<Stack>
|
<ThemeProvider>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<ThemedChrome>
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
<AccountsProvider>
|
||||||
</Stack>
|
<AppServices>
|
||||||
|
<RootNavigator />
|
||||||
|
</AppServices>
|
||||||
|
</AccountsProvider>
|
||||||
|
</ThemedChrome>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootNavigator() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <LoadingScreen message="Checking session…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(session?.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Protected guard={!isAuthenticated}>
|
||||||
|
<Stack.Screen name="(auth)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={isAuthenticated}>
|
||||||
|
<Stack.Screen name="(app)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
|
||||||
import { Text, View } from '@/components/Themed';
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Modal</Text>
|
|
||||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
|
||||||
<EditScreenInfo path="app/modal.tsx" />
|
|
||||||
|
|
||||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
|
||||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
marginVertical: 30,
|
|
||||||
height: 1,
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 252 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-1363.75,-282.196)">
|
||||||
|
<g transform="matrix(1,0,0,1,1343.05,673.674)">
|
||||||
|
<g transform="matrix(488.128,0,0,488.128,0,0)">
|
||||||
|
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "gray:0.75000,1.00000",
|
||||||
|
"orientation" : {
|
||||||
|
"start" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0
|
||||||
|
},
|
||||||
|
"stop" : {
|
||||||
|
"x" : 0.5,
|
||||||
|
"y" : 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : {
|
||||||
|
"solid" : "extended-gray:0.00000,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "extended-gray:1.00000,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "extended-gray:0.50000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass" : true,
|
||||||
|
"image-name" : "beenvoice.svg",
|
||||||
|
"name" : "beenvoice",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.85,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 2970 436" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-15.2844,-243.314)">
|
||||||
|
<g transform="matrix(1,0,0,1,-42.8493,2.19437)">
|
||||||
|
<g transform="matrix(1.05907,0,0,1.05907,-1187.92,22.2126)">
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1157.01,576.339)">
|
||||||
|
<path d="M0.262,0.09L0.262,-0.802L0.341,-0.802L0.341,0.09L0.262,0.09ZM0.307,0.012C0.255,0.012 0.21,0.002 0.171,-0.018C0.133,-0.038 0.103,-0.066 0.081,-0.103C0.059,-0.14 0.046,-0.184 0.042,-0.236L0.164,-0.243C0.169,-0.21 0.177,-0.183 0.19,-0.162C0.202,-0.14 0.219,-0.123 0.239,-0.112C0.259,-0.101 0.283,-0.096 0.311,-0.096C0.34,-0.096 0.364,-0.099 0.383,-0.106C0.402,-0.113 0.416,-0.123 0.425,-0.136C0.435,-0.149 0.44,-0.165 0.44,-0.184C0.44,-0.204 0.435,-0.221 0.426,-0.236C0.417,-0.25 0.4,-0.262 0.375,-0.274C0.349,-0.285 0.313,-0.297 0.265,-0.308C0.219,-0.32 0.181,-0.334 0.15,-0.352C0.12,-0.369 0.097,-0.391 0.082,-0.417C0.067,-0.443 0.059,-0.474 0.059,-0.51C0.059,-0.551 0.068,-0.587 0.087,-0.617C0.106,-0.647 0.133,-0.671 0.169,-0.687C0.205,-0.704 0.248,-0.712 0.299,-0.712C0.349,-0.712 0.392,-0.703 0.427,-0.685C0.463,-0.667 0.491,-0.641 0.511,-0.607C0.531,-0.573 0.544,-0.533 0.548,-0.486L0.426,-0.48C0.422,-0.506 0.416,-0.528 0.406,-0.547C0.396,-0.565 0.382,-0.58 0.364,-0.59C0.346,-0.599 0.323,-0.604 0.295,-0.604C0.257,-0.604 0.227,-0.597 0.207,-0.581C0.186,-0.565 0.175,-0.543 0.175,-0.516C0.175,-0.496 0.18,-0.48 0.188,-0.468C0.197,-0.455 0.212,-0.444 0.235,-0.435C0.257,-0.425 0.289,-0.415 0.33,-0.404C0.387,-0.389 0.432,-0.372 0.465,-0.353C0.498,-0.333 0.522,-0.31 0.536,-0.284C0.55,-0.257 0.558,-0.225 0.558,-0.187C0.558,-0.146 0.547,-0.111 0.527,-0.081C0.507,-0.051 0.478,-0.028 0.44,-0.012C0.403,0.004 0.359,0.012 0.307,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1515.12,576.339)">
|
||||||
|
<path d="M0.35,0.012C0.312,0.012 0.28,0.003 0.252,-0.014C0.225,-0.032 0.204,-0.055 0.189,-0.083L0.186,-0L0.074,-0L0.074,-0.71L0.192,-0.71L0.192,-0.459C0.206,-0.483 0.226,-0.504 0.254,-0.521C0.281,-0.538 0.313,-0.546 0.35,-0.546C0.394,-0.546 0.433,-0.535 0.466,-0.513C0.498,-0.49 0.523,-0.458 0.541,-0.417C0.559,-0.375 0.568,-0.325 0.568,-0.267C0.568,-0.209 0.559,-0.159 0.541,-0.117C0.523,-0.076 0.498,-0.044 0.466,-0.021C0.433,0.001 0.394,0.012 0.35,0.012ZM0.322,-0.094C0.362,-0.094 0.392,-0.11 0.413,-0.14C0.434,-0.17 0.445,-0.212 0.445,-0.267C0.445,-0.322 0.434,-0.364 0.413,-0.395C0.392,-0.425 0.362,-0.44 0.324,-0.44C0.297,-0.44 0.274,-0.433 0.254,-0.42C0.234,-0.406 0.219,-0.387 0.208,-0.361C0.198,-0.335 0.192,-0.304 0.192,-0.267C0.192,-0.231 0.198,-0.2 0.209,-0.174C0.219,-0.148 0.234,-0.129 0.254,-0.115C0.273,-0.101 0.296,-0.094 0.322,-0.094Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,1791.66,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2068.19,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2344.73,576.339)">
|
||||||
|
<path d="M0.076,-0L0.076,-0.534L0.184,-0.534L0.188,-0.391L0.176,-0.398C0.182,-0.432 0.194,-0.46 0.21,-0.482C0.227,-0.504 0.248,-0.52 0.272,-0.53C0.296,-0.541 0.323,-0.546 0.351,-0.546C0.391,-0.546 0.423,-0.537 0.448,-0.52C0.473,-0.502 0.492,-0.478 0.505,-0.448C0.518,-0.418 0.524,-0.384 0.524,-0.345L0.524,-0L0.406,-0L0.406,-0.317C0.406,-0.36 0.398,-0.392 0.382,-0.413C0.367,-0.434 0.343,-0.445 0.311,-0.445C0.29,-0.445 0.27,-0.44 0.253,-0.43C0.235,-0.42 0.221,-0.405 0.21,-0.385C0.2,-0.366 0.194,-0.342 0.194,-0.313L0.194,-0L0.076,-0Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2621.27,576.339)">
|
||||||
|
<path d="M0.227,-0L0.04,-0.534L0.167,-0.534L0.3,-0.128L0.433,-0.534L0.56,-0.534L0.373,-0L0.227,-0Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,2897.8,576.339)">
|
||||||
|
<path d="M0.3,0.012C0.25,0.012 0.206,0.001 0.169,-0.022C0.131,-0.045 0.102,-0.077 0.081,-0.119C0.06,-0.161 0.05,-0.21 0.05,-0.267C0.05,-0.324 0.06,-0.373 0.081,-0.415C0.102,-0.457 0.131,-0.489 0.169,-0.512C0.206,-0.535 0.25,-0.546 0.3,-0.546C0.35,-0.546 0.394,-0.535 0.431,-0.512C0.469,-0.489 0.498,-0.457 0.519,-0.415C0.54,-0.373 0.55,-0.324 0.55,-0.267C0.55,-0.21 0.54,-0.161 0.519,-0.119C0.498,-0.077 0.469,-0.045 0.431,-0.022C0.394,0.001 0.35,0.012 0.3,0.012ZM0.3,-0.094C0.34,-0.094 0.372,-0.11 0.394,-0.14C0.416,-0.17 0.427,-0.212 0.427,-0.267C0.427,-0.322 0.416,-0.364 0.394,-0.395C0.372,-0.425 0.34,-0.44 0.3,-0.44C0.26,-0.44 0.228,-0.425 0.206,-0.395C0.184,-0.364 0.173,-0.322 0.173,-0.267C0.173,-0.212 0.184,-0.17 0.206,-0.14C0.228,-0.11 0.26,-0.094 0.3,-0.094Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3174.34,576.339)">
|
||||||
|
<path d="M0.276,-0L0.276,-0.534L0.394,-0.534L0.394,-0L0.276,-0ZM0.072,-0L0.072,-0.096L0.568,-0.096L0.568,-0L0.072,-0ZM0.082,-0.438L0.082,-0.534L0.377,-0.534L0.377,-0.438L0.082,-0.438ZM0.271,-0.605L0.271,-0.717L0.391,-0.717L0.391,-0.605L0.271,-0.605Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3450.88,576.339)">
|
||||||
|
<path d="M0.311,0.012C0.26,0.012 0.216,0 0.178,-0.023C0.14,-0.046 0.11,-0.079 0.089,-0.121C0.068,-0.162 0.057,-0.211 0.057,-0.267C0.057,-0.323 0.068,-0.371 0.089,-0.413C0.11,-0.455 0.14,-0.487 0.178,-0.511C0.216,-0.534 0.26,-0.546 0.311,-0.546C0.352,-0.546 0.389,-0.538 0.422,-0.522C0.456,-0.506 0.483,-0.483 0.505,-0.454C0.526,-0.424 0.54,-0.389 0.546,-0.348L0.427,-0.341C0.42,-0.373 0.406,-0.397 0.386,-0.414C0.366,-0.431 0.341,-0.44 0.312,-0.44C0.271,-0.44 0.239,-0.424 0.215,-0.394C0.192,-0.363 0.18,-0.321 0.18,-0.267C0.18,-0.213 0.192,-0.171 0.215,-0.141C0.239,-0.11 0.271,-0.094 0.312,-0.094C0.341,-0.094 0.367,-0.103 0.388,-0.121C0.409,-0.139 0.423,-0.165 0.43,-0.2L0.549,-0.193C0.543,-0.152 0.528,-0.116 0.507,-0.085C0.485,-0.055 0.457,-0.031 0.423,-0.014C0.39,0.003 0.352,0.012 0.311,0.012Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,3727.41,576.339)">
|
||||||
|
<path d="M0.305,0.012C0.255,0.012 0.212,0.001 0.174,-0.022C0.136,-0.045 0.107,-0.077 0.086,-0.119C0.065,-0.161 0.055,-0.21 0.055,-0.267C0.055,-0.323 0.065,-0.371 0.086,-0.413C0.107,-0.455 0.136,-0.487 0.173,-0.511C0.21,-0.534 0.253,-0.546 0.303,-0.546C0.351,-0.546 0.394,-0.535 0.431,-0.512C0.468,-0.489 0.496,-0.457 0.517,-0.415C0.538,-0.373 0.549,-0.323 0.549,-0.265L0.549,-0.234L0.177,-0.234C0.181,-0.188 0.194,-0.154 0.217,-0.13C0.24,-0.106 0.27,-0.094 0.307,-0.094C0.336,-0.094 0.359,-0.101 0.378,-0.115C0.397,-0.128 0.41,-0.146 0.418,-0.168L0.539,-0.159C0.522,-0.106 0.494,-0.064 0.454,-0.034C0.414,-0.003 0.364,0.012 0.305,0.012ZM0.178,-0.32L0.421,-0.32C0.418,-0.361 0.405,-0.391 0.384,-0.41C0.362,-0.43 0.335,-0.44 0.302,-0.44C0.268,-0.44 0.241,-0.429 0.219,-0.409C0.198,-0.389 0.184,-0.359 0.178,-0.32Z" style="fill:rgb(101,101,101);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(460.901,0,0,460.901,4003.95,576.339)">
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
|
||||||
|
/** Auth screens — brand grid/blob behind content. */
|
||||||
|
export function AuthBackground({ style, children, ...props }: ViewProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, style]} {...props}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App tab/stack screens — brand grid/blob behind content (native tabs block the root layer). */
|
||||||
|
export function AppBackground({ style, children, ...props }: ViewProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, style]} {...props}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { LogoMark } from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppLock } from "@/contexts/AppLockContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export function AppLockOverlay() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
isLocked,
|
||||||
|
biometricEnabled,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
unlockWithPin,
|
||||||
|
unlockWithBiometric,
|
||||||
|
} = useAppLock();
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked) {
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void unlockWithBiometric().then((success) => {
|
||||||
|
if (!success) return;
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
});
|
||||||
|
}, [enabled, isLocked, biometricEnabled, biometricAvailable, unlockWithBiometric]);
|
||||||
|
|
||||||
|
if (!enabled || !isLocked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPin() {
|
||||||
|
const success = await unlockWithPin(pin);
|
||||||
|
if (success) {
|
||||||
|
setPin("");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("Incorrect PIN");
|
||||||
|
setPin("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryBiometric() {
|
||||||
|
const success = await unlockWithBiometric();
|
||||||
|
if (!success) {
|
||||||
|
setError(`Could not unlock with ${biometricLabel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="fade" transparent={false}>
|
||||||
|
<View style={[styles.screen, { backgroundColor: colors.background }]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<LogoMark size={56} />
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>beenvoice is locked</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
|
||||||
|
Enter your PIN to continue
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={pin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
style={[
|
||||||
|
styles.pinInput,
|
||||||
|
{
|
||||||
|
color: colors.foreground,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
onSubmitEditing={() => void submitPin()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
|
||||||
|
|
||||||
|
{biometricEnabled && biometricAvailable ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => void tryBiometric()}
|
||||||
|
style={styles.biometricButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="finger-print-outline" size={20} color={colors.primary} />
|
||||||
|
<Text style={[styles.biometricLabel, { color: colors.primary }]}>
|
||||||
|
Unlock with {biometricLabel}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
pinInput: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 280,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 52,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontSize: 24,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 8,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
biometricButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
biometricLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { StyleSheet, useWindowDimensions, View, type ViewProps } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import Svg, { Circle, Defs, Line, RadialGradient, Stop } from "react-native-svg";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { blobAnimation, blobDiameter } from "@/lib/beenvoice-theme";
|
||||||
|
import { getBackgroundTokens } from "@/lib/theme-palette";
|
||||||
|
|
||||||
|
export function BrandBackground({ style, ...props }: ViewProps) {
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const tokens = useMemo(() => getBackgroundTokens(colorScheme), [colorScheme]);
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
const gridLines = useMemo(() => {
|
||||||
|
const vertical: Array<{ key: string; x: number }> = [];
|
||||||
|
const horizontal: Array<{ key: string; y: number }> = [];
|
||||||
|
for (let x = 0; x <= width; x += tokens.gridSize) {
|
||||||
|
vertical.push({ key: `v-${x}`, x });
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= height; y += tokens.gridSize) {
|
||||||
|
horizontal.push({ key: `h-${y}`, y });
|
||||||
|
}
|
||||||
|
return { vertical, horizontal };
|
||||||
|
}, [width, height, tokens.gridSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.root, { backgroundColor: tokens.background }, style]}
|
||||||
|
pointerEvents="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
|
||||||
|
{gridLines.vertical.map((line) => (
|
||||||
|
<Line
|
||||||
|
key={line.key}
|
||||||
|
x1={line.x}
|
||||||
|
y1={0}
|
||||||
|
x2={line.x}
|
||||||
|
y2={height}
|
||||||
|
stroke={tokens.gridLine}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{gridLines.horizontal.map((line) => (
|
||||||
|
<Line
|
||||||
|
key={line.key}
|
||||||
|
x1={0}
|
||||||
|
y1={line.y}
|
||||||
|
x2={width}
|
||||||
|
y2={line.y}
|
||||||
|
stroke={tokens.gridLine}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
|
||||||
|
<AmbientBlob cx={cx} cy={cy} blobCore={tokens.blobCore} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmbientBlob({ cx, cy, blobCore }: { cx: number; cy: number; blobCore: string }) {
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const r = blobDiameter / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withRepeat(
|
||||||
|
withTiming(1, {
|
||||||
|
duration: blobAnimation.durationMs,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const k = blobAnimation.keyframes;
|
||||||
|
const t = progress.value;
|
||||||
|
const seg = t < 0.33 ? 0 : t < 0.66 ? 1 : 2;
|
||||||
|
const local = seg === 0 ? t / 0.33 : seg === 1 ? (t - 0.33) / 0.33 : (t - 0.66) / 0.34;
|
||||||
|
const from = k[seg]!;
|
||||||
|
const to = k[seg + 1] ?? k[0]!;
|
||||||
|
const lerp = (a: number, b: number) => a + (b - a) * local;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{ translateX: lerp(from.translateX, to.translateX) },
|
||||||
|
{ translateY: lerp(from.translateY, to.translateY) },
|
||||||
|
{ scale: lerp(from.scale, to.scale) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.blobLayer, animatedStyle]} pointerEvents="none">
|
||||||
|
<Svg width={blobDiameter * 1.6} height={blobDiameter * 1.6}>
|
||||||
|
<Defs>
|
||||||
|
<RadialGradient id="blob-a" cx="50%" cy="50%" r="50%">
|
||||||
|
<Stop offset="0%" stopColor={blobCore} stopOpacity={0.9} />
|
||||||
|
<Stop offset="38%" stopColor={blobCore} stopOpacity={0.35} />
|
||||||
|
<Stop offset="62%" stopColor={blobCore} stopOpacity={0.1} />
|
||||||
|
<Stop offset="100%" stopColor={blobCore} stopOpacity={0} />
|
||||||
|
</RadialGradient>
|
||||||
|
</Defs>
|
||||||
|
<Circle cx={blobDiameter * 0.8} cy={blobDiameter * 0.8} r={r} fill="url(#blob-a)" />
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
},
|
||||||
|
blobLayer: {
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
width: blobDiameter * 1.6,
|
||||||
|
height: blobDiameter * 1.6,
|
||||||
|
marginLeft: -(blobDiameter * 0.8),
|
||||||
|
marginTop: -(blobDiameter * 0.8),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { router } from "expo-router";
|
||||||
|
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatElapsedHoursMinutes } from "@/lib/time-clock";
|
||||||
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
/** Green dot + elapsed time when a timer is running; tappable to open the clock. */
|
||||||
|
export function ClockedInIndicator() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
const running = runningQuery.data;
|
||||||
|
const elapsed = useRunningElapsed(running?.startedAt);
|
||||||
|
|
||||||
|
if (!running) return null;
|
||||||
|
|
||||||
|
const label = formatElapsedHoursMinutes(elapsed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Clocked in, ${label}`}
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={() => router.push("/(app)/timer")}
|
||||||
|
style={styles.hit}
|
||||||
|
>
|
||||||
|
<View style={[styles.row, { backgroundColor: colors.successBg }]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: colors.success }]} />
|
||||||
|
<Text style={[styles.time, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
hit: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
minHeight: 28,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { hasConfiguredInstanceUrl } from "@/lib/accounts";
|
||||||
|
|
||||||
|
type CollapsibleServerFieldProps = {
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatServerLabel(url: string) {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleServerField({ defaultExpanded = false }: CollapsibleServerFieldProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasConfiguredInstanceUrl().then((configured) => {
|
||||||
|
if (!configured) setExpanded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(apiUrl);
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === apiUrl) {
|
||||||
|
setError(null);
|
||||||
|
setExpanded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await setInstanceUrl(trimmed);
|
||||||
|
setValue(saved);
|
||||||
|
setError(null);
|
||||||
|
setExpanded(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save server URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.wrapper,
|
||||||
|
{ paddingBottom: Math.max(insets.bottom, spacing.sm) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ expanded }}
|
||||||
|
onPress={() => setExpanded((open) => !open)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
|
||||||
|
Server ·{" "}
|
||||||
|
<Text style={[styles.host, { color: colors.foreground }]}>
|
||||||
|
{formatServerLabel(apiUrl)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-down" : "chevron-up"}
|
||||||
|
size={16}
|
||||||
|
color={colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.panel,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Server instance"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
onBlur={commit}
|
||||||
|
onSubmitEditing={commit}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder="beenvoice.app or localhost:3000"
|
||||||
|
error={error ?? undefined}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||||
|
Use your Mac's LAN IP on a physical device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
flexDirection: "column-reverse",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
minHeight: 36,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link } from 'expo-router';
|
import { Link, type Href } from 'expo-router';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
@@ -8,7 +8,7 @@ export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> &
|
|||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
{...props}
|
{...props}
|
||||||
href={props.href}
|
href={props.href as Href}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
if (Platform.OS !== 'web') {
|
if (Platform.OS !== 'web') {
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type FilterChipProps = {
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilterChip({ label, active, onPress }: FilterChipProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
|
||||||
|
active && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.chipInner}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.label,
|
||||||
|
{ color: colors.mutedForeground },
|
||||||
|
active && { color: colors.primaryForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
chip: {
|
||||||
|
height: 32,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
chipInner: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Platform, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { blurIntensity, radius, shadowMd, shadowSm } from "@/lib/beenvoice-theme";
|
||||||
|
|
||||||
|
type GlassSurfaceProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
radius?: number;
|
||||||
|
variant?: "card" | "stat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GlassSurface({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
radius: cornerRadius = radius.lg,
|
||||||
|
variant = "card",
|
||||||
|
}: GlassSurfaceProps) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const flat = StyleSheet.flatten(style);
|
||||||
|
const isStat = variant === "stat";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.shell,
|
||||||
|
isStat ? styles.statShell : null,
|
||||||
|
{ borderRadius: cornerRadius, borderColor: colors.borderGlass },
|
||||||
|
isStat ? shadowMd : shadowSm,
|
||||||
|
flat,
|
||||||
|
Platform.OS === "android" ? { backgroundColor: colors.cardGlass } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={blurIntensity.card}
|
||||||
|
tint={isDark ? "dark" : "light"}
|
||||||
|
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassChrome({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
radius: cornerRadius = 0,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
radius?: number;
|
||||||
|
}) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.chromeShell,
|
||||||
|
{ borderRadius: cornerRadius, backgroundColor: colors.cardGlass },
|
||||||
|
StyleSheet.flatten(style),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={blurIntensity.chrome}
|
||||||
|
tint={isDark ? "dark" : "light"}
|
||||||
|
style={[StyleSheet.absoluteFill, { borderRadius: cornerRadius }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
{ backgroundColor: colors.cardGlass, borderRadius: cornerRadius },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{children ? <View style={styles.content}>{children}</View> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
shell: {
|
||||||
|
overflow: "hidden",
|
||||||
|
borderWidth: StyleSheet.hairlineWidth * 2,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
statShell: {
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
chromeShell: {
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAccounts } from "@/contexts/AccountsContext";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { normalizeInstanceUrl } from "@/lib/instance-url";
|
||||||
|
|
||||||
|
type InstanceUrlFieldProps = {
|
||||||
|
onSaved?: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstanceUrlField({ onSaved }: InstanceUrlFieldProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||||
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(apiUrl);
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === apiUrl) {
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeInstanceUrl(trimmed);
|
||||||
|
if (!normalized) {
|
||||||
|
setError("Enter a valid URL like beenvoice.app or localhost:3000");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await setInstanceUrl(trimmed);
|
||||||
|
setValue(saved);
|
||||||
|
setError(null);
|
||||||
|
onSaved?.(saved);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not save server URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Input
|
||||||
|
label="Server instance"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
onBlur={commit}
|
||||||
|
onSubmitEditing={commit}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
placeholder="beenvoice.app or localhost:3000"
|
||||||
|
error={error ?? undefined}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||||
|
Point the app at your beenvoice server. Use your Mac's LAN IP on a physical device.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { BrandBackground } from "@/components/BrandBackground";
|
||||||
|
import { LogoMark } from "@/components/Logo";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
export function LoadingScreen({ message = "Loading…" }: { message?: string }) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<BrandBackground />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<LogoMark />
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { StyleSheet, Text, View, type ImageStyle, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts } from "@/constants/theme";
|
||||||
|
|
||||||
|
type LogoSize = "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
const widths: Record<LogoSize, number> = {
|
||||||
|
xs: 104,
|
||||||
|
sm: 140,
|
||||||
|
md: 180,
|
||||||
|
lg: 220,
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoProps = {
|
||||||
|
size?: LogoSize;
|
||||||
|
style?: ViewStyle;
|
||||||
|
/** Force the light wordmark for dark backgrounds (e.g. status bar chrome). */
|
||||||
|
onDark?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full beenvoice wordmark from web `public/beenvoice-logo.png` */
|
||||||
|
export function Logo({ size = "md", style, onDark }: LogoProps) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
const width = widths[size];
|
||||||
|
const height = width * (436 / 2970);
|
||||||
|
const useDarkAsset = onDark ?? isDark;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.row, styles.noShrink, style]}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
useDarkAsset
|
||||||
|
? require("@/assets/images/beenvoice-logo-dark.png")
|
||||||
|
: require("@/assets/images/beenvoice-logo.png")
|
||||||
|
}
|
||||||
|
style={{ width, height }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square app icon mark — fixed aspect ratio so flex parents cannot squash it. */
|
||||||
|
export function LogoMark({
|
||||||
|
size = 32,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
style?: ImageStyle;
|
||||||
|
}) {
|
||||||
|
const flat = StyleSheet.flatten(style);
|
||||||
|
const width =
|
||||||
|
typeof flat?.width === "number"
|
||||||
|
? flat.width
|
||||||
|
: typeof flat?.height === "number"
|
||||||
|
? flat.height
|
||||||
|
: size;
|
||||||
|
const height = typeof flat?.height === "number" ? flat.height : width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.markBox, { width, height }]}>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/icon.png")}
|
||||||
|
style={styles.markImage}
|
||||||
|
contentFit="contain"
|
||||||
|
accessibilityLabel="beenvoice"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadingText({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: object;
|
||||||
|
}) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text style={[styles.heading, { color: colors.foreground }, style]}>{children}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
noShrink: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
markBox: {
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
markImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { tabLayout } from "@/lib/tab-layout";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useTopChromeHeight } from "@/lib/top-chrome-insets";
|
||||||
|
|
||||||
|
type PageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Title block — transparent, scrolls under TopChromeBar blur. */
|
||||||
|
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const topChromeHeight = useTopChromeHeight();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[tabLayout.pageHeader, { paddingTop: topChromeHeight + spacing.md }]}>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { isValidPin } from "@/lib/app-lock";
|
||||||
|
|
||||||
|
type PinPromptProps = {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
requireConfirmation?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (pin: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PinPrompt({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Continue",
|
||||||
|
requireConfirmation = false,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: PinPromptProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [confirmPin, setConfirmPin] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setPin("");
|
||||||
|
setConfirmPin("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!isValidPin(pin)) {
|
||||||
|
setError("PIN must be 4–6 digits");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireConfirmation && pin !== confirmPin) {
|
||||||
|
setError("PINs do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onCancel}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||||
|
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={pin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{requireConfirmation ? (
|
||||||
|
<TextInput
|
||||||
|
value={confirmPin}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setError("");
|
||||||
|
setConfirmPin(value.replace(/\D/g, "").slice(0, 6));
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
secureTextEntry
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="Confirm PIN"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button title="Cancel" variant="secondary" onPress={onCancel} />
|
||||||
|
<Button title={confirmLabel} onPress={handleSubmit} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.35)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 6,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyleSheet, type ViewStyle } from "react-native";
|
||||||
|
import { SafeAreaView, type Edge } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScreenProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: ViewStyle;
|
||||||
|
/**
|
||||||
|
* Safe area edges to pad. Default: top + sides (Dynamic Island / notch).
|
||||||
|
* Tab screens usually omit bottom — the tab bar handles home-indicator spacing.
|
||||||
|
*/
|
||||||
|
edges?: Edge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full-screen wrapper that respects Dynamic Island, notch, and side insets. */
|
||||||
|
export function Screen({ children, style, edges = ["top", "left", "right"] }: ScreenProps) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.screen, style]} edges={edges}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth / modal screens that aren't inside a tab bar. */
|
||||||
|
export function FullScreen({ children, style }: Omit<ScreenProps, "edges">) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.screen, style]} edges={["top", "bottom", "left", "right"]}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { StyleSheet, Text } from "react-native";
|
||||||
|
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Web `StatsCard` — glass card, border-0, shadow-md, p-6 */
|
||||||
|
export function StatCard({ label, value, hint }: StatCardProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="stat" style={styles.card}>
|
||||||
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
|
||||||
|
{hint ? (
|
||||||
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: "46%",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontFamily: fonts.heading,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { getStatusColor, statusLabels, type InvoiceStatus } from "@/lib/invoice-status";
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
const color = getStatusColor(status, isDark);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { backgroundColor: `${color}22` }]}>
|
||||||
|
<Text style={[styles.text, { color }]}>{statusLabels[status]}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
height: 22,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: fonts.bodyBold,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { TopChromeBar } from "@/components/TopChromeBar";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
type TabPageProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tab root — floating blurred top chrome; children should be a TabScrollView. */
|
||||||
|
export function TabPage({ children }: TabPageProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
{ paddingLeft: insets.left, paddingRight: insets.right },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
<TopChromeBar />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
import { tabLayout } from "@/lib/tab-layout";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
|
||||||
|
type TabScrollViewProps = ScrollViewProps & {
|
||||||
|
/** Rendered above screen body — scrolls under the blurred top chrome. */
|
||||||
|
header?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scroll view for native tab screens — content scrolls under the tab bar. */
|
||||||
|
export function TabScrollView({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
contentContainerStyle,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: TabScrollViewProps) {
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.scroll, style]}
|
||||||
|
contentContainerStyle={[
|
||||||
|
tabLayout.scrollContent,
|
||||||
|
{ paddingBottom: scrollPadding },
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<View style={tabLayout.scrollBody}>{children}</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
import { ClockedInIndicator } from "@/components/ClockedInIndicator";
|
||||||
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
|
||||||
|
|
||||||
|
/** Wordmark left, clocked-in indicator right — sits on TopChromeBar blur. */
|
||||||
|
export function TopChrome() {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Logo size="xs" onDark={isDark} />
|
||||||
|
<ClockedInIndicator />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
height: TOP_CHROME_ROW_HEIGHT,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
import { TopChrome } from "@/components/TopChrome";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { blurIntensity } from "@/lib/beenvoice-theme";
|
||||||
|
import {
|
||||||
|
TOP_CHROME_PADDING_BOTTOM,
|
||||||
|
TOP_CHROME_ROW_HEIGHT,
|
||||||
|
} from "@/lib/top-chrome-insets";
|
||||||
|
|
||||||
|
/** Blurred status-bar chrome with logo + clocked-in indicator. */
|
||||||
|
export function TopChromeBar() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
const tint = isDark ? "rgba(9, 9, 11, 0.28)" : "rgba(255, 255, 255, 0.32)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.host,
|
||||||
|
{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: TOP_CHROME_PADDING_BOTTOM,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
height: insets.top + TOP_CHROME_ROW_HEIGHT + TOP_CHROME_PADDING_BOTTOM,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={blurIntensity.chrome}
|
||||||
|
tint={isDark ? "dark" : "light"}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[StyleSheet.absoluteFill, { backgroundColor: tint }]}
|
||||||
|
/>
|
||||||
|
<TopChrome />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
host: {
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
|
|
||||||
|
export type EditableLineItem = {
|
||||||
|
id?: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: string;
|
||||||
|
rate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineItemEditorProps = {
|
||||||
|
item: EditableLineItem;
|
||||||
|
currency: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onChange: (patch: Partial<EditableLineItem>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LineItemEditor({
|
||||||
|
item,
|
||||||
|
currency,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: LineItemEditorProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const hours = Number(item.hours) || 0;
|
||||||
|
const rate = Number(item.rate) || 0;
|
||||||
|
const amount = hours * rate;
|
||||||
|
const borderStyle = { borderTopColor: colors.border };
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onToggle}
|
||||||
|
style={({ pressed }) => [styles.row, borderStyle, pressed && styles.rowPressed]}
|
||||||
|
>
|
||||||
|
<View style={styles.rowMain}>
|
||||||
|
<Text style={[styles.rowTitle, { color: colors.foreground }]} numberOfLines={1}>
|
||||||
|
{item.description.trim() || "Untitled line"}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.rowSub, { color: colors.mutedForeground }]}>
|
||||||
|
{formatDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rowAmount, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(amount, currency)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.expanded, borderStyle]}>
|
||||||
|
<View style={styles.expandedHeader}>
|
||||||
|
<Text style={[styles.expandedLabel, { color: colors.mutedForeground }]}>Line item</Text>
|
||||||
|
<Pressable accessibilityRole="button" onPress={onToggle} hitSlop={8}>
|
||||||
|
<Ionicons name="chevron-up" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(description) => onChange({ description })}
|
||||||
|
placeholder="What was done"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.inlineRow}>
|
||||||
|
<View style={styles.inlineField}>
|
||||||
|
<Input
|
||||||
|
label="Hours"
|
||||||
|
value={item.hours}
|
||||||
|
onChangeText={(hours) => onChange({ hours })}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.inlineField}>
|
||||||
|
<Input
|
||||||
|
label="Rate"
|
||||||
|
value={item.rate}
|
||||||
|
onChangeText={(rate) => onChange({ rate })}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Date"
|
||||||
|
mode="date"
|
||||||
|
value={item.date}
|
||||||
|
onChange={(date) => onChange({ date })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.expandedFooter}>
|
||||||
|
<Text style={[styles.lineTotal, { color: colors.foreground }]}>
|
||||||
|
{formatCurrency(amount, currency)}
|
||||||
|
</Text>
|
||||||
|
<Pressable accessibilityRole="button" onPress={onRemove} style={styles.removeButton}>
|
||||||
|
<Ionicons name="trash-outline" size={16} color={colors.destructive} />
|
||||||
|
<Text style={[styles.removeLabel, { color: colors.destructive }]}>Remove</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
rowPressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
rowMain: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
rowSub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
rowAmount: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
expanded: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
expandedHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
expandedLabel: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 13,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
inlineRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
inlineField: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
expandedFooter: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
lineTotal: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
removeLabel: {
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { Alert, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { DateTimeField } from "@/components/ui/DateTimeField";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { SelectField } from "@/components/ui/SelectField";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
|
||||||
|
import { tabLayout } from "@/lib/tab-layout";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
|
import {
|
||||||
|
endTimeClockLiveActivity,
|
||||||
|
syncTimeClockLiveActivity,
|
||||||
|
} from "@/lib/time-clock-live-activity";
|
||||||
|
import { describeClockOutOutcome, formatElapsedSeconds } from "@/lib/time-clock";
|
||||||
|
import { useRunningElapsed } from "@/lib/use-running-elapsed";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
|
export type TimeClockPanelProps = {
|
||||||
|
defaultClientId?: string;
|
||||||
|
defaultInvoiceId?: string;
|
||||||
|
/** Hides the in-panel title card when idle (tab screen already has PageHeader). */
|
||||||
|
compact?: boolean;
|
||||||
|
header?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeClockPanel({
|
||||||
|
defaultClientId = "",
|
||||||
|
defaultInvoiceId = "",
|
||||||
|
compact = false,
|
||||||
|
header,
|
||||||
|
}: TimeClockPanelProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = useThemedStyles(createTimeClockStyles);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
const clientsQuery = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(defaultClientId);
|
||||||
|
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [rateText, setRateText] = useState("");
|
||||||
|
const [startedAt, setStartedAt] = useState(() => new Date());
|
||||||
|
|
||||||
|
const running = runningQuery.data;
|
||||||
|
const elapsed = useRunningElapsed(running?.startedAt);
|
||||||
|
const clients = clientsQuery.data ?? [];
|
||||||
|
const activeClientId = running?.clientId ?? clientId;
|
||||||
|
|
||||||
|
const billableQuery = api.invoices.getBillable.useQuery(
|
||||||
|
activeClientId ? { clientId: activeClientId } : undefined,
|
||||||
|
);
|
||||||
|
const billableInvoices = billableQuery.data ?? [];
|
||||||
|
|
||||||
|
const todayStart = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
|
||||||
|
const scrollPadding = useTabBarScrollPadding();
|
||||||
|
|
||||||
|
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.timeEntries.getRunning.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getRunning.invalidate(),
|
||||||
|
utils.invoices.getBillable.invalidate(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
Alert.alert("Could not update timer", err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await endTimeClockLiveActivity();
|
||||||
|
const message = describeClockOutOutcome({
|
||||||
|
outcome: data.outcome,
|
||||||
|
hours: data.hours,
|
||||||
|
rate: data.rate,
|
||||||
|
invoice: data.invoice,
|
||||||
|
});
|
||||||
|
Alert.alert(
|
||||||
|
data.outcome === "linked_to_invoice" ? "Time logged" : "Timer stopped",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
await Promise.all([
|
||||||
|
utils.timeEntries.getRunning.invalidate(),
|
||||||
|
utils.timeEntries.getAll.invalidate(),
|
||||||
|
utils.invoices.getAll.invalidate(),
|
||||||
|
utils.invoices.getBillable.invalidate(),
|
||||||
|
utils.dashboard.getStats.invalidate(),
|
||||||
|
]);
|
||||||
|
setDescription("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
setClientId(running.clientId ?? "");
|
||||||
|
setInvoiceId(running.invoiceId ?? "");
|
||||||
|
setDescription(running.description);
|
||||||
|
setRateText(running.rate != null ? String(running.rate) : "");
|
||||||
|
}, [running]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running || !clientId || rateText) return;
|
||||||
|
const client = clients.find((c) => c.id === clientId);
|
||||||
|
if (client?.defaultHourlyRate) {
|
||||||
|
setRateText(String(client.defaultHourlyRate));
|
||||||
|
}
|
||||||
|
}, [clientId, clients, rateText, running]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
void endTimeClockLiveActivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const seconds = Math.floor(
|
||||||
|
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
|
||||||
|
);
|
||||||
|
void syncTimeClockLiveActivity(
|
||||||
|
{ ...running, description },
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
const interval = setInterval(sync, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [running, description]);
|
||||||
|
|
||||||
|
const rate = parseFloat(rateText) || 0;
|
||||||
|
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||||
|
|
||||||
|
const clientOptions = useMemo(
|
||||||
|
() => clients.map((client) => ({ label: client.name, value: client.id })),
|
||||||
|
[clients],
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "No invoice — save entry only", value: "" },
|
||||||
|
...billableInvoices.map((invoice) => ({
|
||||||
|
label: `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber} (${invoice.status})`,
|
||||||
|
value: invoice.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[billableInvoices],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleClockIn() {
|
||||||
|
try {
|
||||||
|
const backdated =
|
||||||
|
Math.abs(Date.now() - startedAt.getTime()) > 60_000 ? startedAt : undefined;
|
||||||
|
await clockIn.mutateAsync({
|
||||||
|
description: description.trim(),
|
||||||
|
clientId: clientId || "",
|
||||||
|
invoiceId: invoiceId || undefined,
|
||||||
|
rate: rate || undefined,
|
||||||
|
startedAt: backdated,
|
||||||
|
});
|
||||||
|
setStartedAt(new Date());
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClockOut() {
|
||||||
|
try {
|
||||||
|
await clockOut.mutateAsync({ description: description.trim() || undefined });
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRunningClientChange(nextClientId: string) {
|
||||||
|
if (!running) return;
|
||||||
|
setClientId(nextClientId);
|
||||||
|
setInvoiceId("");
|
||||||
|
try {
|
||||||
|
await updateRunning.mutateAsync({ clientId: nextClientId, invoiceId: "" });
|
||||||
|
const client = clients.find((c) => c.id === nextClientId);
|
||||||
|
if (client?.defaultHourlyRate != null) {
|
||||||
|
setRateText(String(client.defaultHourlyRate));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setClientId(running.clientId ?? "");
|
||||||
|
setInvoiceId(running.invoiceId ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRunningInvoiceChange(nextInvoiceId: string) {
|
||||||
|
if (!running) return;
|
||||||
|
const previous = invoiceId;
|
||||||
|
setInvoiceId(nextInvoiceId);
|
||||||
|
try {
|
||||||
|
await updateRunning.mutateAsync({ invoiceId: nextInvoiceId });
|
||||||
|
} catch {
|
||||||
|
setInvoiceId(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningQuery.isLoading || clientsQuery.isLoading) {
|
||||||
|
return <LoadingScreen message="Loading time clock…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayEntries = (todayQuery.data ?? []).filter((entry) => entry.endedAt);
|
||||||
|
const runningMeta = [
|
||||||
|
running?.client?.name ?? (running ? "No client" : null),
|
||||||
|
running?.invoice
|
||||||
|
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
|
: null,
|
||||||
|
displayRate ? `$${displayRate}/hr` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]}
|
||||||
|
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
|
||||||
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={runningQuery.isRefetching}
|
||||||
|
onRefresh={() => {
|
||||||
|
void runningQuery.refetch();
|
||||||
|
void clientsQuery.refetch();
|
||||||
|
void billableQuery.refetch();
|
||||||
|
void todayQuery.refetch();
|
||||||
|
}}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<View style={tabLayout.scrollBody}>
|
||||||
|
{running || !compact ? (
|
||||||
|
<GlassSurface style={running ? styles.runningCard : undefined}>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
{running ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.heroHeader}>
|
||||||
|
<View style={styles.pulseDot} />
|
||||||
|
<Text style={styles.heroLabel}>Running</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
|
||||||
|
<Text style={styles.runningTitle}>
|
||||||
|
{description.trim() || "No description"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.runningMeta}>
|
||||||
|
Started {formatDateTime(running.startedAt)}
|
||||||
|
{runningMeta ? ` · ${runningMeta}` : ""}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.idleHint}>Track billable time and link it to invoices.</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<Card style={styles.formCard}>
|
||||||
|
<View style={styles.formFields}>
|
||||||
|
<SelectField
|
||||||
|
label="Client"
|
||||||
|
placeholder="Select client…"
|
||||||
|
value={clientId}
|
||||||
|
options={clientOptions}
|
||||||
|
disabled={updateRunning.isPending}
|
||||||
|
onValueChange={(next) => void handleRunningClientChange(next)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label="Invoice"
|
||||||
|
placeholder="No invoice — save entry only"
|
||||||
|
value={invoiceId}
|
||||||
|
options={invoiceOptions}
|
||||||
|
disabled={updateRunning.isPending}
|
||||||
|
onValueChange={(next) => void handleRunningInvoiceChange(next)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card style={styles.formCard}>
|
||||||
|
<View style={styles.formFields}>
|
||||||
|
<SelectField
|
||||||
|
label="Client"
|
||||||
|
placeholder="Select client…"
|
||||||
|
value={clientId}
|
||||||
|
options={clientOptions}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
setClientId(next);
|
||||||
|
setInvoiceId("");
|
||||||
|
const client = clients.find((c) => c.id === next);
|
||||||
|
setRateText(
|
||||||
|
client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label="Invoice"
|
||||||
|
placeholder="No invoice — save entry only"
|
||||||
|
value={invoiceId}
|
||||||
|
options={invoiceOptions}
|
||||||
|
disabled={!clientId}
|
||||||
|
onValueChange={setInvoiceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Hourly rate"
|
||||||
|
value={rateText}
|
||||||
|
onChangeText={setRateText}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateTimeField
|
||||||
|
label="Started at"
|
||||||
|
value={startedAt}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
onChange={setStartedAt}
|
||||||
|
/>
|
||||||
|
<Text style={styles.startedHint}>
|
||||||
|
Set an earlier time if you forgot to clock in when you started working.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<Button
|
||||||
|
title="Clock out"
|
||||||
|
variant="danger"
|
||||||
|
loading={clockOut.isPending}
|
||||||
|
onPress={handleClockOut}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button title="Clock in" loading={clockIn.isPending} onPress={handleClockIn} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todayEntries.length > 0 ? (
|
||||||
|
<Card title="Today">
|
||||||
|
{todayEntries.map((entry) => {
|
||||||
|
const invoiceLabel = entry.invoice
|
||||||
|
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const row = (
|
||||||
|
<>
|
||||||
|
<View style={styles.entryMeta}>
|
||||||
|
<Text style={styles.entryTitle}>{entry.description || "No description"}</Text>
|
||||||
|
<Text style={styles.entrySub}>
|
||||||
|
{entry.client?.name ?? "No client"}
|
||||||
|
{invoiceLabel ? ` · ${invoiceLabel}` : " · not billed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.entryHours}>{entry.hours ?? "—"}h</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry.invoice) {
|
||||||
|
return (
|
||||||
|
<View key={entry.id} style={styles.entryRow}>
|
||||||
|
{row}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={entry.id}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`View invoice ${invoiceLabel}`}
|
||||||
|
onPress={() => router.push(`/(app)/invoices/${entry.invoice!.id}`)}
|
||||||
|
style={({ pressed }) => [styles.entryRow, pressed && styles.entryRowPressed]}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
runningCard: {
|
||||||
|
borderColor: isDark ? "rgba(74, 222, 128, 0.35)" : "rgba(26, 26, 26, 0.18)",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
heroHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
pulseDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
heroLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
timerValue: {
|
||||||
|
fontSize: 52,
|
||||||
|
lineHeight: 56,
|
||||||
|
fontFamily: fonts.mono,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
runningTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
runningMeta: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
},
|
||||||
|
idleHint: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
startedHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
lineHeight: 18,
|
||||||
|
marginTop: -spacing.xs,
|
||||||
|
},
|
||||||
|
formCard: {
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
formFields: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
entryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
entryRowPressed: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
entryMeta: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
entryTitle: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
entrySub: {
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
entryHours: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
color: colors.foreground,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
type PressableProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
|
||||||
|
type ButtonProps = PressableProps & {
|
||||||
|
title: string;
|
||||||
|
loading?: boolean;
|
||||||
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
style?: ViewStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
title,
|
||||||
|
loading,
|
||||||
|
variant = "primary",
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary: { backgroundColor: colors.primary },
|
||||||
|
secondary: {
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
backgroundColor: colors.destructiveBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.destructive,
|
||||||
|
},
|
||||||
|
ghost: { backgroundColor: "transparent" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const labelStyles = {
|
||||||
|
primary: { color: colors.primaryForeground },
|
||||||
|
secondary: { color: colors.foreground },
|
||||||
|
danger: { color: colors.destructive },
|
||||||
|
ghost: { color: colors.foreground },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.base,
|
||||||
|
variantStyles[variant],
|
||||||
|
pressed && !isDisabled && styles.pressed,
|
||||||
|
isDisabled && styles.disabled,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
color={variant === "primary" ? colors.primaryForeground : colors.primary}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.label, labelStyles[variant]]}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
base: {
|
||||||
|
minHeight: 40,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { StyleSheet, Text, View, type StyleProp, type ViewProps, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
import { GlassSurface } from "@/components/GlassSurface";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, spacing } from "@/constants/theme";
|
||||||
|
import { radius } from "@/lib/beenvoice-theme";
|
||||||
|
|
||||||
|
type CardProps = ViewProps & {
|
||||||
|
title?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
variant?: "card" | "stat";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Card({ title, style, children, variant = "card", ...props }: CardProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface style={StyleSheet.flatten(style)} radius={radius.lg} variant={variant}>
|
||||||
|
<View style={styles.inner} {...props}>
|
||||||
|
{title ? <Text style={[styles.title, { color: colors.foreground }]}>{title}</Text> : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
inner: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import DateTimePicker, {
|
||||||
|
type DateTimePickerEvent,
|
||||||
|
} from "@react-native-community/datetimepicker";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { formatDate, formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type DateTimeFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value: Date;
|
||||||
|
mode?: "date" | "datetime";
|
||||||
|
maximumDate?: Date;
|
||||||
|
minimumDate?: Date;
|
||||||
|
onChange: (date: Date) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DateTimeField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mode = "datetime",
|
||||||
|
maximumDate = new Date(),
|
||||||
|
minimumDate,
|
||||||
|
onChange,
|
||||||
|
}: DateTimeFieldProps) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
setDraft(value);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDate(next: Date) {
|
||||||
|
const clamped =
|
||||||
|
next.getTime() > maximumDate.getTime()
|
||||||
|
? maximumDate
|
||||||
|
: minimumDate && next.getTime() < minimumDate.getTime()
|
||||||
|
? minimumDate
|
||||||
|
: next;
|
||||||
|
onChange(clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(event: DateTimePickerEvent, selected?: Date) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
setOpen(false);
|
||||||
|
if (event.type === "set" && selected) {
|
||||||
|
applyDate(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
setDraft(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={openPicker}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.trigger,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
pressed && styles.triggerPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.value, { color: colors.foreground }]}>
|
||||||
|
{mode === "date" ? formatDate(value) : formatDateTime(value)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="calendar-outline" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<Modal visible={open} transparent animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: colors.card }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||||
|
<Pressable onPress={() => setOpen(false)}>
|
||||||
|
<Text style={[styles.sheetAction, { color: colors.mutedForeground }]}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
applyDate(draft);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.sheetAction, { color: colors.primary }]}>Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<DateTimePicker
|
||||||
|
value={draft}
|
||||||
|
mode={mode}
|
||||||
|
display="spinner"
|
||||||
|
maximumDate={maximumDate}
|
||||||
|
minimumDate={minimumDate}
|
||||||
|
themeVariant={isDark ? "dark" : "light"}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
) : open ? (
|
||||||
|
<DateTimePicker
|
||||||
|
value={draft}
|
||||||
|
mode={mode}
|
||||||
|
maximumDate={maximumDate}
|
||||||
|
minimumDate={minimumDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
triggerPressed: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.45)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
borderTopLeftRadius: radii.lg,
|
||||||
|
borderTopRightRadius: radii.lg,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
sheetHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
sheetTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
sheetAction: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
|
||||||
|
type InputProps = TextInputProps & {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Input({ label, error, style, ...props }: InputProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
color: colors.foreground,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
error && { borderColor: colors.destructive },
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 40,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectFieldProps = {
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
}: SelectFieldProps) {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.trigger,
|
||||||
|
{
|
||||||
|
borderColor: colors.borderGlass,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
|
disabled && styles.triggerDisabled,
|
||||||
|
pressed && !disabled && styles.triggerPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.triggerText,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
!selected && { color: colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{selected?.label ?? placeholder}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
transparent
|
||||||
|
visible={open}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, { backgroundColor: colors.background }]}
|
||||||
|
onPress={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
|
||||||
|
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
|
||||||
|
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
|
||||||
|
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value || "__empty__"}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => {
|
||||||
|
onValueChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.option,
|
||||||
|
isSelected && { backgroundColor: colors.muted },
|
||||||
|
pressed && styles.optionPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.optionText,
|
||||||
|
{ color: colors.foreground },
|
||||||
|
isSelected && styles.optionTextSelected,
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
minHeight: 44,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
triggerDisabled: {
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
|
triggerPressed: {
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
triggerText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
maxHeight: "70%",
|
||||||
|
borderTopLeftRadius: radii.xl,
|
||||||
|
borderTopRightRadius: radii.xl,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
sheetHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
sheetTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.bodyMedium,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
minHeight: 48,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
optionPressed: {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
},
|
||||||
|
optionTextSelected: {
|
||||||
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Mobile palette + spacing — re-exports canonical tokens from lib/beenvoice-theme.ts
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
background,
|
||||||
|
border,
|
||||||
|
border50,
|
||||||
|
foreground,
|
||||||
|
muted,
|
||||||
|
mutedForeground,
|
||||||
|
primary,
|
||||||
|
primaryForeground,
|
||||||
|
radius,
|
||||||
|
shadowMd,
|
||||||
|
shadowSm,
|
||||||
|
surface80,
|
||||||
|
} from "@/lib/beenvoice-theme";
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
background,
|
||||||
|
backgroundMuted: muted,
|
||||||
|
foreground,
|
||||||
|
card: background,
|
||||||
|
cardGlass: surface80,
|
||||||
|
primary,
|
||||||
|
primaryForeground,
|
||||||
|
muted,
|
||||||
|
mutedForeground,
|
||||||
|
border,
|
||||||
|
borderGlass: border50,
|
||||||
|
secondary: border,
|
||||||
|
secondaryForeground: primary,
|
||||||
|
accent: muted,
|
||||||
|
destructive: "#EF4444",
|
||||||
|
destructiveForeground: primaryForeground,
|
||||||
|
destructiveBg: "#FEF2F2",
|
||||||
|
success: "#16A34A",
|
||||||
|
successBg: "#F0FDF4",
|
||||||
|
warning: "#D97706",
|
||||||
|
warningBg: "#FFFBEB",
|
||||||
|
brand: primary,
|
||||||
|
brandDark: foreground,
|
||||||
|
text: foreground,
|
||||||
|
textMuted: mutedForeground,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const radii = {
|
||||||
|
sm: radius.sm,
|
||||||
|
md: radius.md,
|
||||||
|
lg: radius.lg,
|
||||||
|
xl: radius.xl,
|
||||||
|
card: radius.lg,
|
||||||
|
pill: radius.pill,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fonts = {
|
||||||
|
heading: "PlayfairDisplay_700Bold",
|
||||||
|
headingSemi: "PlayfairDisplay_600SemiBold",
|
||||||
|
body: "Inter_400Regular",
|
||||||
|
bodyMedium: "Inter_500Medium",
|
||||||
|
bodySemiBold: "Inter_600SemiBold",
|
||||||
|
bodyBold: "Inter_700Bold",
|
||||||
|
mono: "SpaceMono",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const layout = {
|
||||||
|
/** Bottom inset when tab bar is `position: absolute` */
|
||||||
|
tabBarInset: 96,
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { LoadingScreen } from "@/components/LoadingScreen";
|
||||||
|
import {
|
||||||
|
authStoragePrefix,
|
||||||
|
buildAccountId,
|
||||||
|
loadAccounts,
|
||||||
|
loadActiveAccountId,
|
||||||
|
loadDraftInstanceUrl,
|
||||||
|
saveAccounts,
|
||||||
|
saveActiveAccountId,
|
||||||
|
saveDraftInstanceUrl,
|
||||||
|
type SavedAccount,
|
||||||
|
} from "@/lib/accounts";
|
||||||
|
import { setRuntimeApiUrl, getApiUrl } from "@/lib/config";
|
||||||
|
import { normalizeInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
|
||||||
|
|
||||||
|
type AccountsContextValue = {
|
||||||
|
accounts: SavedAccount[];
|
||||||
|
activeAccount: SavedAccount | null;
|
||||||
|
activeAccountId: string | null;
|
||||||
|
apiUrl: string;
|
||||||
|
authStoragePrefix: string;
|
||||||
|
setInstanceUrl: (url: string) => Promise<string>;
|
||||||
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
|
registerAccount: (input: {
|
||||||
|
instanceUrl: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}) => Promise<SavedAccount>;
|
||||||
|
removeAccount: (accountId: string) => Promise<void>;
|
||||||
|
clearActiveAccount: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountsContext = createContext<AccountsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AccountsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<SavedAccount[]>([]);
|
||||||
|
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
|
||||||
|
const [apiUrl, setApiUrl] = useState(getApiUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([loadAccounts(), loadActiveAccountId(), loadDraftInstanceUrl()])
|
||||||
|
.then(([storedAccounts, activeId, draftUrl]) => {
|
||||||
|
setAccounts(storedAccounts);
|
||||||
|
|
||||||
|
const active = storedAccounts.find((account) => account.id === activeId) ?? null;
|
||||||
|
if (active) {
|
||||||
|
setActiveAccountId(active.id);
|
||||||
|
setRuntimeApiUrl(active.instanceUrl);
|
||||||
|
} else if (draftUrl) {
|
||||||
|
setRuntimeApiUrl(draftUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setApiUrl(getApiUrl());
|
||||||
|
})
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeAccount = useMemo(
|
||||||
|
() => accounts.find((account) => account.id === activeAccountId) ?? null,
|
||||||
|
[accounts, activeAccountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setInstanceUrl = useCallback(
|
||||||
|
async (url: string) => {
|
||||||
|
const normalized = normalizeInstanceUrl(url);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeAccount) {
|
||||||
|
const nextAccounts = accounts.map((account) =>
|
||||||
|
account.id === activeAccount.id ? { ...account, instanceUrl: normalized } : account,
|
||||||
|
);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
await saveAccounts(nextAccounts);
|
||||||
|
} else {
|
||||||
|
await saveDraftInstanceUrl(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveStoredInstanceUrl(normalized);
|
||||||
|
setRuntimeApiUrl(normalized);
|
||||||
|
setApiUrl(normalized);
|
||||||
|
return normalized;
|
||||||
|
},
|
||||||
|
[activeAccount, accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const switchAccount = useCallback(
|
||||||
|
async (accountId: string) => {
|
||||||
|
const account = accounts.find((entry) => entry.id === accountId);
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
const nextAccounts = accounts.map((entry) =>
|
||||||
|
entry.id === accountId ? { ...entry, lastUsedAt: Date.now() } : entry,
|
||||||
|
);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
await saveAccounts(nextAccounts);
|
||||||
|
await saveActiveAccountId(accountId);
|
||||||
|
setActiveAccountId(accountId);
|
||||||
|
setRuntimeApiUrl(account.instanceUrl);
|
||||||
|
setApiUrl(account.instanceUrl);
|
||||||
|
},
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerAccount = useCallback(
|
||||||
|
async (input: { instanceUrl: string; userId: string; email: string; name: string }) => {
|
||||||
|
const id = buildAccountId(input.instanceUrl, input.userId);
|
||||||
|
const existing = accounts.find((account) => account.id === id);
|
||||||
|
const account: SavedAccount = {
|
||||||
|
id,
|
||||||
|
instanceUrl: input.instanceUrl,
|
||||||
|
userId: input.userId,
|
||||||
|
email: input.email,
|
||||||
|
name: input.name,
|
||||||
|
lastUsedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextAccounts = existing
|
||||||
|
? accounts.map((entry) => (entry.id === id ? account : entry))
|
||||||
|
: [account, ...accounts.filter((entry) => entry.id !== id)];
|
||||||
|
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
await saveAccounts(nextAccounts);
|
||||||
|
await saveActiveAccountId(id);
|
||||||
|
await saveDraftInstanceUrl(null);
|
||||||
|
setActiveAccountId(id);
|
||||||
|
setRuntimeApiUrl(input.instanceUrl);
|
||||||
|
setApiUrl(input.instanceUrl);
|
||||||
|
await saveStoredInstanceUrl(input.instanceUrl);
|
||||||
|
return account;
|
||||||
|
},
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeAccount = useCallback(
|
||||||
|
async (accountId: string) => {
|
||||||
|
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
await saveAccounts(nextAccounts);
|
||||||
|
|
||||||
|
if (activeAccountId === accountId) {
|
||||||
|
const fallback = nextAccounts[0] ?? null;
|
||||||
|
await saveActiveAccountId(fallback?.id ?? null);
|
||||||
|
setActiveAccountId(fallback?.id ?? null);
|
||||||
|
if (fallback) {
|
||||||
|
setRuntimeApiUrl(fallback.instanceUrl);
|
||||||
|
setApiUrl(fallback.instanceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accounts, activeAccountId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearActiveAccount = useCallback(async () => {
|
||||||
|
await saveActiveAccountId(null);
|
||||||
|
setActiveAccountId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
accounts,
|
||||||
|
activeAccount,
|
||||||
|
activeAccountId,
|
||||||
|
apiUrl,
|
||||||
|
authStoragePrefix: activeAccount
|
||||||
|
? authStoragePrefix(activeAccount.id)
|
||||||
|
: "beenvoice:guest",
|
||||||
|
setInstanceUrl,
|
||||||
|
switchAccount,
|
||||||
|
registerAccount,
|
||||||
|
removeAccount,
|
||||||
|
clearActiveAccount,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
accounts,
|
||||||
|
activeAccount,
|
||||||
|
activeAccountId,
|
||||||
|
apiUrl,
|
||||||
|
setInstanceUrl,
|
||||||
|
switchAccount,
|
||||||
|
registerAccount,
|
||||||
|
removeAccount,
|
||||||
|
clearActiveAccount,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return <LoadingScreen message="Starting…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AccountsContext.Provider value={value}>{children}</AccountsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccounts() {
|
||||||
|
const ctx = useContext(AccountsContext);
|
||||||
|
if (!ctx) throw new Error("useAccounts must be used within AccountsProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import * as LocalAuthentication from "expo-local-authentication";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearStoredPin,
|
||||||
|
getAppLockEnabled,
|
||||||
|
getBiometricEnabled,
|
||||||
|
getStoredPin,
|
||||||
|
isValidPin,
|
||||||
|
setAppLockEnabled,
|
||||||
|
setBiometricEnabled,
|
||||||
|
setStoredPin,
|
||||||
|
} from "@/lib/app-lock";
|
||||||
|
|
||||||
|
type AppLockContextValue = {
|
||||||
|
enabled: boolean;
|
||||||
|
biometricEnabled: boolean;
|
||||||
|
hasPin: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
biometricAvailable: boolean;
|
||||||
|
biometricLabel: string;
|
||||||
|
unlockWithPin: (pin: string) => Promise<boolean>;
|
||||||
|
unlockWithBiometric: () => Promise<boolean>;
|
||||||
|
enableLock: (pin: string) => Promise<void>;
|
||||||
|
disableLock: (pin: string) => Promise<boolean>;
|
||||||
|
changePin: (currentPin: string, nextPin: string) => Promise<boolean>;
|
||||||
|
setUseBiometric: (enabled: boolean) => Promise<void>;
|
||||||
|
lock: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppLockContext = createContext<AppLockContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AppLockProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [biometricEnabled, setBiometricEnabledState] = useState(false);
|
||||||
|
const [hasPin, setHasPin] = useState(false);
|
||||||
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
|
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
||||||
|
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
|
||||||
|
const wasBackgrounded = useRef(false);
|
||||||
|
const hydrated = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function hydrate() {
|
||||||
|
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
|
||||||
|
await Promise.all([
|
||||||
|
getAppLockEnabled(),
|
||||||
|
getStoredPin(),
|
||||||
|
getBiometricEnabled(),
|
||||||
|
LocalAuthentication.hasHardwareAsync(),
|
||||||
|
LocalAuthentication.isEnrolledAsync(),
|
||||||
|
LocalAuthentication.supportedAuthenticationTypesAsync(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const bioAvailable = hasHardware && isEnrolled;
|
||||||
|
setEnabled(lockEnabled);
|
||||||
|
setHasPin(Boolean(pin));
|
||||||
|
setBiometricEnabledState(bioEnabled && bioAvailable);
|
||||||
|
setBiometricAvailable(bioAvailable);
|
||||||
|
setBiometricLabel(
|
||||||
|
authTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
|
||||||
|
? "Face ID"
|
||||||
|
: authTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)
|
||||||
|
? "Touch ID"
|
||||||
|
: "Biometrics",
|
||||||
|
);
|
||||||
|
setIsLocked(lockEnabled);
|
||||||
|
hydrated.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void hydrate();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||||
|
if (!hydrated.current || !enabled) return;
|
||||||
|
|
||||||
|
if (nextState === "background" || nextState === "inactive") {
|
||||||
|
wasBackgrounded.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextState === "active" && wasBackgrounded.current) {
|
||||||
|
wasBackgrounded.current = false;
|
||||||
|
setIsLocked(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const unlockWithPin = useCallback(async (pin: string) => {
|
||||||
|
const stored = await getStoredPin();
|
||||||
|
if (!stored || stored !== pin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setIsLocked(false);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unlockWithBiometric = useCallback(async () => {
|
||||||
|
if (!biometricEnabled || !biometricAvailable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: "Unlock beenvoice",
|
||||||
|
cancelLabel: "Use PIN",
|
||||||
|
disableDeviceFallback: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLocked(false);
|
||||||
|
return true;
|
||||||
|
}, [biometricAvailable, biometricEnabled]);
|
||||||
|
|
||||||
|
const enableLock = useCallback(async (pin: string) => {
|
||||||
|
if (!isValidPin(pin)) {
|
||||||
|
throw new Error("PIN must be 4–6 digits");
|
||||||
|
}
|
||||||
|
await setStoredPin(pin);
|
||||||
|
await setAppLockEnabled(true);
|
||||||
|
setHasPin(true);
|
||||||
|
setEnabled(true);
|
||||||
|
setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disableLock = useCallback(async (pin: string) => {
|
||||||
|
const stored = await getStoredPin();
|
||||||
|
if (!stored || stored !== pin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await setAppLockEnabled(false);
|
||||||
|
await clearStoredPin();
|
||||||
|
await setBiometricEnabled(false);
|
||||||
|
setEnabled(false);
|
||||||
|
setHasPin(false);
|
||||||
|
setBiometricEnabledState(false);
|
||||||
|
setIsLocked(false);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changePin = useCallback(async (currentPin: string, nextPin: string) => {
|
||||||
|
const stored = await getStoredPin();
|
||||||
|
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await setStoredPin(nextPin);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setUseBiometric = useCallback(async (next: boolean) => {
|
||||||
|
if (next) {
|
||||||
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: `Enable ${biometricLabel}`,
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
disableDeviceFallback: true,
|
||||||
|
});
|
||||||
|
if (!result.success) return;
|
||||||
|
}
|
||||||
|
await setBiometricEnabled(next);
|
||||||
|
setBiometricEnabledState(next);
|
||||||
|
}, [biometricLabel]);
|
||||||
|
|
||||||
|
const lock = useCallback(() => {
|
||||||
|
if (enabled) {
|
||||||
|
setIsLocked(true);
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
enabled,
|
||||||
|
biometricEnabled,
|
||||||
|
hasPin,
|
||||||
|
isLocked,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
unlockWithPin,
|
||||||
|
unlockWithBiometric,
|
||||||
|
enableLock,
|
||||||
|
disableLock,
|
||||||
|
changePin,
|
||||||
|
setUseBiometric,
|
||||||
|
lock,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
enabled,
|
||||||
|
biometricEnabled,
|
||||||
|
hasPin,
|
||||||
|
isLocked,
|
||||||
|
biometricAvailable,
|
||||||
|
biometricLabel,
|
||||||
|
unlockWithPin,
|
||||||
|
unlockWithBiometric,
|
||||||
|
enableLock,
|
||||||
|
disableLock,
|
||||||
|
changePin,
|
||||||
|
setUseBiometric,
|
||||||
|
lock,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AppLockContext.Provider value={value}>{children}</AppLockContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppLock() {
|
||||||
|
const context = useContext(AppLockContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAppLock must be used within AppLockProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expoClient } from "@better-auth/expo/client";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthClient | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({
|
||||||
|
apiUrl,
|
||||||
|
storagePrefix,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
apiUrl: string;
|
||||||
|
storagePrefix: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const client = useMemo(
|
||||||
|
() =>
|
||||||
|
createAuthClient({
|
||||||
|
baseURL: apiUrl,
|
||||||
|
plugins: [
|
||||||
|
expoClient({
|
||||||
|
scheme: "beenvoice",
|
||||||
|
storagePrefix,
|
||||||
|
storage: SecureStore,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[apiUrl, storagePrefix],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={client}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthClient() {
|
||||||
|
const client = useContext(AuthContext);
|
||||||
|
if (!client) throw new Error("useAuthClient must be used within AuthProvider");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useAuthClient().useSession();
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useColorScheme as useSystemColorScheme, type ColorSchemeName } from "react-native";
|
||||||
|
|
||||||
|
import { getThemeColors, type ThemeColors } from "@/lib/theme-palette";
|
||||||
|
|
||||||
|
export type ColorMode = "system" | "light" | "dark";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "beenvoice:color-mode";
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
colorMode: ColorMode;
|
||||||
|
setColorMode: (mode: ColorMode) => Promise<void>;
|
||||||
|
colorScheme: NonNullable<ColorSchemeName>;
|
||||||
|
colors: ThemeColors;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const systemScheme = useSystemColorScheme();
|
||||||
|
const [colorMode, setColorModeState] = useState<ColorMode>("system");
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem(STORAGE_KEY)
|
||||||
|
.then((stored) => {
|
||||||
|
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||||
|
setColorModeState(stored);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme: NonNullable<ColorSchemeName> =
|
||||||
|
colorMode === "system" ? (systemScheme ?? "light") : colorMode;
|
||||||
|
|
||||||
|
const colors = useMemo(() => getThemeColors(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const setColorMode = useCallback(async (mode: ColorMode) => {
|
||||||
|
setColorModeState(mode);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
colorMode,
|
||||||
|
setColorMode,
|
||||||
|
colorScheme,
|
||||||
|
colors,
|
||||||
|
isDark: colorScheme === "dark",
|
||||||
|
}),
|
||||||
|
[colorMode, setColorMode, colorScheme, colors],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
colorMode: "system",
|
||||||
|
setColorMode: async () => {},
|
||||||
|
colorScheme: systemScheme ?? "light",
|
||||||
|
colors: getThemeColors(systemScheme ?? "light"),
|
||||||
|
isDark: systemScheme === "dark",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppTheme() {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useAppTheme must be used within ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.transformer = {
|
||||||
|
...config.transformer,
|
||||||
|
babelTransformerPath: require.resolve("react-native-svg-transformer/expo"),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.resolver = {
|
||||||
|
...config.resolver,
|
||||||
|
assetExts: config.resolver.assetExts.filter((ext) => ext !== "svg"),
|
||||||
|
sourceExts: [...config.resolver.sourceExts, "svg"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -3,33 +3,57 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/expo": "^1.6.19",
|
||||||
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
|
"@expo-google-fonts/playfair-display": "^0.4.2",
|
||||||
|
"@expo/ui": "~56.0.18",
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/datetimepicker": "9.1.0",
|
||||||
|
"@react-native-picker/picker": "^2.11.4",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@trpc/client": "^11.17.0",
|
||||||
|
"@trpc/react-query": "^11.17.0",
|
||||||
|
"better-auth": "^1.6.19",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
|
"expo-blur": "~56.0.3",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
|
"expo-dev-client": "~56.0.20",
|
||||||
"expo-font": "~56.0.7",
|
"expo-font": "~56.0.7",
|
||||||
|
"expo-image": "^56.0.11",
|
||||||
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
|
"expo-local-authentication": "~56.0.4",
|
||||||
|
"expo-network": "^56.0.5",
|
||||||
"expo-router": "~56.2.11",
|
"expo-router": "~56.2.11",
|
||||||
|
"expo-secure-store": "^56.0.4",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
"expo-symbols": "~56.0.6",
|
"expo-symbols": "~56.0.6",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~56.0.5",
|
||||||
|
"expo-widgets": "~56.0.19",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-reanimated": "4.3.1",
|
"react-native-reanimated": "4.3.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.8.3"
|
"react-native-worklets": "0.8.3",
|
||||||
|
"superjson": "^2.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"react-native-svg-transformer": "^1.5.3",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start --dev-client --port 8082",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android --port 8082",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios --port 8082",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"packageManager": "bun@1.3.14"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 232 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "*.svg" {
|
||||||
|
import type { FC } from "react";
|
||||||
|
import type { SvgProps } from "react-native-svg";
|
||||||
|
const content: FC<SvgProps>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"],
|
||||||
"./*"
|
"~/*": ["../beenvoice/src/*"],
|
||||||
]
|
"src/*": ["../beenvoice/src/*"],
|
||||||
|
"beenvoice/*": ["../beenvoice/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { HStack, Text, VStack } from "@expo/ui/swift-ui";
|
||||||
|
import { font, foregroundStyle, padding } from "@expo/ui/swift-ui/modifiers";
|
||||||
|
import { createLiveActivity, type LiveActivityEnvironment } from "expo-widgets";
|
||||||
|
|
||||||
|
import type { TimeClockActivityProps } from "@/lib/time-clock-live-activity.types";
|
||||||
|
|
||||||
|
function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActivityEnvironment) {
|
||||||
|
"widget";
|
||||||
|
|
||||||
|
const title = props.description.trim() || "Timer running";
|
||||||
|
const subtitle = [props.clientName, props.invoiceLabel].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
banner: (
|
||||||
|
<HStack modifiers={[padding({ all: 14 })]}>
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 14 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
beenvoice
|
||||||
|
</Text>
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 20 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
{props.elapsedShort}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
compactLeading: (
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
bv
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
compactTrailing: (
|
||||||
|
<Text modifiers={[font({ weight: "semibold", size: 14 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
{props.elapsedShort}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
minimal: (
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
{props.elapsedShort}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
expandedLeading: (
|
||||||
|
<VStack modifiers={[padding({ all: 12 })]}>
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 16 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
beenvoice
|
||||||
|
</Text>
|
||||||
|
<Text modifiers={[font({ size: 12 }), foregroundStyle("#E5E5E5")]}>
|
||||||
|
{props.clockTime}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
expandedTrailing: (
|
||||||
|
<VStack modifiers={[padding({ all: 12 })]}>
|
||||||
|
<Text modifiers={[font({ weight: "bold", size: 32 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
{props.elapsedShort}
|
||||||
|
</Text>
|
||||||
|
<Text modifiers={[font({ size: 12 }), foregroundStyle("#E5E5E5")]}>elapsed</Text>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
expandedBottom: (
|
||||||
|
<VStack modifiers={[padding({ horizontal: 12, bottom: 12 })]}>
|
||||||
|
<Text modifiers={[font({ weight: "semibold", size: 15 }), foregroundStyle("#FFFFFF")]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text modifiers={[font({ size: 13 }), foregroundStyle("#E5E5E5")]}>{subtitle}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text modifiers={[font({ size: 12 }), foregroundStyle("#D4D4D4")]}>
|
||||||
|
{props.elapsed} total
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createLiveActivity("TimeClockActivity", TimeClockActivity);
|
||||||