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": {
|
||||
"name": "beenvoice-app",
|
||||
"slug": "beenvoice-app",
|
||||
"name": "beenvoice",
|
||||
"slug": "beenvoice",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "beenvoiceapp",
|
||||
"scheme": "beenvoice",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.beenvoice.app",
|
||||
"icon": "./assets/beenvoice.icon",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#D9D9D9",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
@@ -25,18 +30,38 @@
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-dev-client",
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#D9D9D9"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
"expo-widgets",
|
||||
{
|
||||
"groupIdentifier": "group.com.beenvoice.app"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-local-authentication",
|
||||
{
|
||||
"faceIDPermission": "Unlock beenvoice with Face ID when returning to the app."
|
||||
}
|
||||
],
|
||||
"@react-native-community/datetimepicker"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import { Stack } from "expo-router";
|
||||
import {
|
||||
Inter_400Regular,
|
||||
Inter_500Medium,
|
||||
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 {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
export { 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();
|
||||
|
||||
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() {
|
||||
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(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
@@ -39,18 +80,43 @@ export default function RootLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
<ThemedChrome>
|
||||
<AccountsProvider>
|
||||
<AppServices>
|
||||
<RootNavigator />
|
||||
</AppServices>
|
||||
</AccountsProvider>
|
||||
</ThemedChrome>
|
||||
</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 type { ComponentProps } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
@@ -8,7 +8,7 @@ export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> &
|
||||
<Link
|
||||
target="_blank"
|
||||
{...props}
|
||||
href={props.href}
|
||||
href={props.href as Href}
|
||||
onPress={(e) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
// 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",
|
||||
"version": "1.0.0",
|
||||
"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-blur": "~56.0.3",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-image": "^56.0.11",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-local-authentication": "~56.0.4",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-router": "~56.2.11",
|
||||
"expo-secure-store": "^56.0.4",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-symbols": "~56.0.6",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"expo-widgets": "~56.0.19",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.8.3"
|
||||
"react-native-worklets": "0.8.3",
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"start": "expo start --dev-client --port 8082",
|
||||
"android": "expo run:android --port 8082",
|
||||
"ios": "expo run:ios --port 8082",
|
||||
"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",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"],
|
||||
"~/*": ["../beenvoice/src/*"],
|
||||
"src/*": ["../beenvoice/src/*"],
|
||||
"beenvoice/*": ["../beenvoice/src/*"]
|
||||
}
|
||||
},
|
||||
"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);
|
||||