Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode";
function initials(name: string, email: string) {
const source = name.trim() || email.trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
}
return (source[0] ?? "?").toUpperCase();
}
function displayName(name: string, email: string) {
const trimmed = name.trim();
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
return email.split("@")[0] ?? email;
}
/** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const label = displayName(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
const avatar = initials(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
async function handleAddAccount() {
setOpen(false);
await startAdditionalAccountSignIn(clearActiveAccount);
}
async function handleSwitch(accountId: string) {
if (accountId === activeAccountId) {
setOpen(false);
return;
}
setOpen(false);
await switchAccount(accountId);
}
async function handleRefresh() {
setRefreshing(true);
try {
await refreshAccounts();
} finally {
setRefreshing(false);
}
}
function handleOpenSettings() {
setOpen(false);
router.push("/(app)/more/settings" as never);
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
if (result.remainingCount === 0) {
setOpen(false);
}
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
},
);
}
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Switch account"
hitSlop={8}
onPress={() => setOpen(true)}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.muted }]}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{avatar}
</Text>
</View>
<Text
style={[styles.name, { color: colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
</View>
</Pressable>
<Modal animationType="fade" 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 }]}>Accounts</Text>
<View style={styles.sheetActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
disabled={refreshing}
hitSlop={8}
onPress={() => void handleRefresh()}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
{refreshing ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="refresh" size={20} color={colors.primary} />
)}
</Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<Pressable
key={account.id}
accessibilityRole="button"
onPress={() => void handleSwitch(account.id)}
style={({ pressed }) => [
styles.accountRow,
{
borderBottomColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{initials(account.name, account.email)}
</Text>
</View>
<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 }]}>
{formatServerHost(account.instanceUrl)}
</Text>
</View>
<View style={styles.accountActions}>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemove(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</Pressable>
);
})}
<Pressable
accessibilityRole="button"
onPress={() => void handleAddAccount()}
style={({ pressed }) => [
styles.addRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={handleOpenSettings}
style={({ pressed }) => [
styles.settingsRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="settings-outline" size={21} color={colors.mutedForeground} />
<Text style={[styles.settingsLabel, { color: colors.foreground }]}>
Settings
</Text>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 1,
maxWidth: "58%",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingLeft: 4,
paddingRight: 8,
minHeight: 32,
borderRadius: radii.pill,
},
avatar: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
avatarText: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
},
name: {
flexShrink: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
maxHeight: "70%",
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
sheetActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
iconButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 28,
minHeight: 28,
},
done: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
accountRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountName: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
accountSub: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
addRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
paddingVertical: spacing.lg,
borderTopWidth: StyleSheet.hairlineWidth,
},
addLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
settingsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
},
settingsLabel: {
flex: 1,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.75,
},
});
+34
View File
@@ -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",
},
});
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import {
Modal,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { Logo } 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("");
const promptedRef = useRef(false);
useEffect(() => {
if (!isLocked) {
setPin("");
setError("");
promptedRef.current = false;
}
}, [isLocked]);
useEffect(() => {
if (!enabled || !isLocked || !biometricEnabled || !biometricAvailable) {
return;
}
if (promptedRef.current) return;
const timer = setTimeout(() => {
promptedRef.current = true;
void unlockWithBiometric().then((success) => {
if (!success) return;
setPin("");
setError("");
});
}, 400);
return () => clearTimeout(timer);
}, [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() {
promptedRef.current = true;
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}>
<Logo size="md" />
<Text style={[styles.title, { color: colors.foreground }]}>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}
<View style={styles.actions}>
<Button title="Unlock" onPress={() => void submitPin()} disabled={pin.length < 4} />
{biometricAvailable ? (
<Button
title={`Unlock with ${biometricLabel}`}
variant="secondary"
onPress={() => void tryBiometric()}
style={styles.biometricButton}
/>
) : null}
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: "center",
padding: spacing.lg,
},
content: {
alignItems: "center",
gap: spacing.md,
width: "100%",
maxWidth: 320,
alignSelf: "center",
},
title: {
fontSize: 22,
fontFamily: fonts.heading,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 20,
},
pinInput: {
width: "100%",
borderWidth: 1,
borderRadius: 12,
minHeight: 52,
paddingHorizontal: spacing.md,
fontSize: 20,
fontFamily: fonts.bodySemiBold,
textAlign: "center",
},
error: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
textAlign: "center",
},
actions: {
width: "100%",
gap: spacing.sm,
},
biometricButton: {
width: "100%",
},
});
+232
View File
@@ -0,0 +1,232 @@
import { Ionicons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { Pressable, 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 { DEFAULT_API_URL, OFFICIAL_SERVER_PLACEHOLDER, invalidServerUrlMessage } from "@/lib/config";
import {
formatServerHost,
isServerConfigValid,
resolveServerMode,
resolveServerUrl,
SERVER_MODE_OPTIONS,
type ServerMode,
} from "@/lib/server-mode";
type AuthServerPickerProps = {
onReadyChange?: (ready: boolean) => void;
/** When true, picker sits inside the auth card with no outer margin. */
embedded?: boolean;
};
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
if (mode === "official") return "Official";
const host = formatServerHost(selfHostedUrl);
return host || "Self-hosted";
}
export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(false);
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
);
const [urlError, setUrlError] = useState<string | null>(null);
const ready = isServerConfigValid(mode, selfHostedUrl);
useEffect(() => {
onReadyChange?.(ready);
}, [ready, onReadyChange]);
useEffect(() => {
const nextMode = resolveServerMode(apiUrl);
setMode(nextMode);
if (nextMode === "self-hosted") {
setSelfHostedUrl(apiUrl);
}
}, [apiUrl]);
async function applyMode(nextMode: ServerMode) {
setMode(nextMode);
setUrlError(null);
if (nextMode === "official") {
try {
await setInstanceUrl(DEFAULT_API_URL);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
return;
}
setExpanded(true);
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) return;
try {
await setInstanceUrl(resolved);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not set server");
}
}
async function commitSelfHostedUrl() {
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
if (!resolved) {
setUrlError(invalidServerUrlMessage());
return;
}
try {
const saved = await setInstanceUrl(resolved);
setSelfHostedUrl(saved);
setUrlError(null);
setExpanded(false);
} catch (err) {
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
}
}
return (
<View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
<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.summary, { color: colors.foreground }]}>
{modeSummary(mode, selfHostedUrl)}
</Text>
</Text>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{expanded ? (
<View
style={[
styles.panel,
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
]}
>
{SERVER_MODE_OPTIONS.map((option) => {
const selected = option.value === mode;
return (
<Pressable
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => void applyMode(option.value)}
style={({ pressed }) => [
styles.option,
{
borderColor: colors.border,
backgroundColor: selected ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
{option.label}
</Text>
{selected ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
</Pressable>
);
})}
{mode === "self-hosted" ? (
<>
<Input
label="Server URL"
value={selfHostedUrl}
onChangeText={(value) => {
setSelfHostedUrl(value);
setUrlError(null);
}}
onBlur={() => void commitSelfHostedUrl()}
onSubmitEditing={() => void commitSelfHostedUrl()}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder={OFFICIAL_SERVER_PLACEHOLDER}
required
error={urlError ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;s LAN IP on a physical device.
</Text>
</>
) : null}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
marginBottom: spacing.md,
},
wrapperEmbedded: {
marginBottom: 0,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
pressed: {
opacity: 0.7,
},
triggerText: {
fontSize: 13,
fontFamily: fonts.body,
},
summary: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
panel: {
borderWidth: 1,
borderRadius: 14,
padding: spacing.md,
gap: spacing.sm,
},
option: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
paddingHorizontal: spacing.md,
borderRadius: 10,
borderWidth: 1,
},
optionLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 16,
},
});
+134
View File
@@ -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,151 @@
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";
import { OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
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={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Use your Mac&apos;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,
},
});
+76
View File
@@ -0,0 +1,76 @@
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});
+22
View File
@@ -0,0 +1,22 @@
import { Link, type Href } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import type { ComponentProps } from 'react';
import { Platform } from 'react-native';
export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> & { href: string }) {
return (
<Link
target="_blank"
{...props}
href={props.href as Href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}
+57
View File
@@ -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,63 @@
import { Pressable, StyleSheet, Text } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii } from "@/constants/theme";
import { useFloatingActionBottom } from "@/lib/tab-bar-insets";
type FloatingActionButtonProps = {
onPress: () => void;
accessibilityLabel?: string;
};
export function FloatingActionButton({
onPress,
accessibilityLabel = "Create",
}: FloatingActionButtonProps) {
const { colors } = useAppTheme();
const bottom = useFloatingActionBottom();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={({ pressed }) => [
styles.fab,
{
bottom,
backgroundColor: colors.primary,
shadowColor: colors.foreground,
},
pressed && styles.pressed,
]}
>
<Text style={[styles.icon, { color: colors.primaryForeground }]}>+</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
fab: {
position: "absolute",
right: 20,
width: 56,
height: 56,
borderRadius: radii.pill,
alignItems: "center",
justifyContent: "center",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 6,
},
pressed: {
opacity: 0.9,
transform: [{ scale: 0.96 }],
},
icon: {
fontSize: 32,
lineHeight: 34,
fontFamily: fonts.body,
marginTop: -2,
},
});
+112
View File
@@ -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,78 @@
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 { invalidServerUrlMessage, OFFICIAL_SERVER_PLACEHOLDER } from "@/lib/config";
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(invalidServerUrlMessage());
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={OFFICIAL_SERVER_PLACEHOLDER}
error={error ?? undefined}
/>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Point the app at your beenvoice server. Use your Mac&apos;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,65 @@
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { syncInvoiceSendReminders } from "@/lib/invoice-send-reminders";
import { api } from "@/lib/trpc";
function openInvoiceFromNotification(data: Record<string, unknown> | undefined) {
if (data?.type !== "invoice-send-reminder") return;
const invoiceId = data.invoiceId;
if (typeof invoiceId !== "string" || !invoiceId) return;
router.push(`/(app)/invoices/${invoiceId}`);
}
/** Schedules local iOS/Android notifications for draft invoice send reminders. */
export function InvoiceReminderSync() {
const utils = api.useUtils();
const invoicesQuery = api.invoices.getAll.useQuery(
{ status: "draft" },
{ staleTime: 60_000 },
);
const wasBackgrounded = useRef(false);
useEffect(() => {
if (!invoicesQuery.data) return;
void syncInvoiceSendReminders(invoicesQuery.data);
}, [invoicesQuery.data]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void utils.invoices.getAll.invalidate({ status: "draft" });
});
return () => subscription.remove();
}, [utils.invoices.getAll]);
useEffect(() => {
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
},
);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (!response) return;
openInvoiceFromNotification(
response.notification.request.content.data as Record<string, unknown>,
);
});
return () => responseSubscription.remove();
}, []);
return null;
}
+45
View File
@@ -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,
},
});
+110
View File
@@ -0,0 +1,110 @@
import { Image } from "expo-image";
import { StyleSheet, Text, View, type ViewStyle } from "react-native";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts } from "@/constants/theme";
const markSource = require("@/assets/images/icon.png");
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 dollar mark from Icon Composer export (1024×1024 PNG). */
export function LogoMark({
size = 32,
style,
}: {
size?: number;
style?: ViewStyle;
}) {
const fromStyle =
typeof style?.width === "number"
? style.width
: typeof style?.height === "number"
? style.height
: undefined;
const dimension = fromStyle ?? size;
return (
<View
style={[
styles.markBox,
{ width: dimension, height: dimension, aspectRatio: 1 },
style,
]}
>
<Image
source={markSource}
style={{ width: dimension, height: dimension }}
contentFit="contain"
/>
</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",
},
heading: {
fontFamily: fonts.heading,
},
});
+19
View File
@@ -0,0 +1,19 @@
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { api } from "@/lib/trpc";
/** Redirect new users into onboarding until they complete setup. */
export function OnboardingGate() {
const statusQuery = api.settings.getOnboardingStatus.useQuery();
const redirected = useRef(false);
useEffect(() => {
if (redirected.current || statusQuery.isLoading || !statusQuery.data) return;
if (statusQuery.data.completed) return;
redirected.current = true;
router.push("/(app)/onboarding" as never);
}, [statusQuery.data, statusQuery.isLoading]);
return null;
}
+35
View File
@@ -0,0 +1,35 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts } from "@/constants/theme";
import { tabLayout } from "@/lib/tab-layout";
import { useAppTheme } from "@/contexts/ThemeContext";
type PageHeaderProps = {
title: string;
subtitle: string;
};
/** Title block — scrolls with tab screen content. */
export function PageHeader({ title, subtitle }: PageHeaderProps) {
const { colors } = useAppTheme();
return (
<View style={tabLayout.pageHeader}>
<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,
},
});
+157
View File
@@ -0,0 +1,157 @@
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 46 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",
},
error: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
});
+38
View File
@@ -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",
},
});
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
import { AppState, type AppStateStatus } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { performAuthReset } from "@/lib/auth-session";
import { isRateLimitError } from "@/lib/trpc-errors";
/** Refetch auth session when the app returns to the foreground. */
export function SessionSync() {
const authClient = useAuthClient();
const { activeAccountId, clearActiveAccount } = useAccounts();
const { data: session, refetch } = useSession();
const wasBackgrounded = useRef(false);
const resettingRef = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
return;
}
if (nextState !== "active" || !wasBackgrounded.current) return;
wasBackgrounded.current = false;
void (async () => {
await refetch();
const next = await authClient.getSession();
if (next.error && isRateLimitError(next.error)) return;
if (next.data?.user) return;
if (!session?.user || resettingRef.current) return;
resettingRef.current = true;
try {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
refetchSession: refetch,
});
} finally {
resettingRef.current = false;
}
})();
});
return () => subscription.remove();
}, [authClient, refetch, session?.user, activeAccountId, clearActiveAccount]);
return null;
}
+154
View File
@@ -0,0 +1,154 @@
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Alert, Platform } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import {
clearPendingShortcut,
peekPendingShortcut,
subscribeShortcutQueue,
} from "@/lib/shortcut-queue";
import {
DEFAULT_CLOCK_DESCRIPTION,
resolveClockDescription,
resolveEffectiveHourlyRate,
} from "@/lib/time-clock";
import { getLastTimeClockClientId } from "@/lib/time-clock-prefs";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import type { ParsedShortcut } from "@/lib/shortcuts";
import { api } from "@/lib/trpc";
/**
* Executes queued shortcut actions once the user is signed in, unlocked, and data is ready.
*/
export function ShortcutHandler() {
const { activeAccountId } = useAccounts();
const { isLocked } = useAppLock();
const utils = api.useUtils();
const clientsQuery = api.clients.getAll.useQuery();
const runningQuery = api.timeEntries.getRunning.useQuery();
const [pending, setPending] = useState<ParsedShortcut | null>(null);
const processingRef = useRef(false);
const clockIn = api.timeEntries.clockIn.useMutation();
const clockOut = api.timeEntries.clockOut.useMutation();
useEffect(() => {
let cancelled = false;
async function refresh() {
const next = await peekPendingShortcut();
if (!cancelled) setPending(next);
}
void refresh();
return subscribeShortcutQueue(() => {
void refresh();
});
}, []);
useEffect(() => {
if (!pending || !activeAccountId || isLocked) return;
if (clientsQuery.isLoading || runningQuery.isLoading) return;
if (processingRef.current) return;
processingRef.current = true;
void (async () => {
try {
if (pending.action === "open-timer") {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
return;
}
if (pending.action === "clock-out") {
if (!runningQuery.data) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("No timer running", "There is nothing to clock out.");
}
return;
}
await clockOut.mutateAsync({});
await endTimeClockLiveActivity();
await Promise.all([
utils.timeEntries.getRunning.invalidate(),
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
return;
}
if (pending.action === "clock-in") {
if (runningQuery.data) {
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
if (Platform.OS === "ios") {
Alert.alert("Timer already running", "Stop the current timer before clocking in again.");
}
return;
}
const clientId =
pending.clientId || (await getLastTimeClockClientId(activeAccountId)) || "";
const client = (clientsQuery.data ?? []).find((entry) => entry.id === clientId);
const rate = resolveEffectiveHourlyRate("", client?.defaultHourlyRate);
await clockIn.mutateAsync({
clientId: clientId || "",
description: resolveClockDescription(pending.title || DEFAULT_CLOCK_DESCRIPTION),
rate: rate ?? undefined,
});
await utils.timeEntries.getRunning.invalidate();
const running = await utils.timeEntries.getRunning.fetch();
if (running) {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
await syncTimeClockLiveActivity(running, seconds);
}
await clearPendingShortcut();
setPending(null);
router.push("/(app)/timer");
}
} catch (err) {
await clearPendingShortcut();
setPending(null);
Alert.alert(
pending.action === "clock-out" ? "Clock out failed" : "Clock in failed",
err instanceof Error ? err.message : "Something went wrong.",
);
router.push("/(app)/timer");
} finally {
processingRef.current = false;
}
})();
}, [
activeAccountId,
clockIn,
clockOut,
clientsQuery.data,
clientsQuery.isLoading,
isLocked,
pending,
runningQuery.data,
runningQuery.isLoading,
utils,
]);
return null;
}
@@ -0,0 +1,30 @@
import * as Linking from "expo-linking";
import { useEffect } from "react";
import { enqueueShortcut } from "@/lib/shortcut-queue";
import { parseShortcutUrl } from "@/lib/shortcuts";
/**
* Captures shortcut deep links as early as possible (before auth / tabs mount).
* Mounted at the app root inside AppServices.
*/
export function ShortcutLinkCapture() {
useEffect(() => {
function capture(url: string | null | undefined) {
const parsed = parseShortcutUrl(url);
if (parsed) {
void enqueueShortcut(parsed);
}
}
void Linking.getInitialURL().then(capture);
const subscription = Linking.addEventListener("url", ({ url }) => {
capture(url);
});
return () => subscription.remove();
}, []);
return null;
}
@@ -0,0 +1,139 @@
import { Ionicons } from "@expo/vector-icons";
import * as Linking from "expo-linking";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { SHORTCUT_URLS } from "@/lib/shortcuts";
const SHORTCUT_ACTIONS = [
{ title: "Clock In", subtitle: "Start timer with your last client", url: SHORTCUT_URLS.clockIn },
{ title: "Clock Out", subtitle: "Stop the running timer", url: SHORTCUT_URLS.clockOut },
{ title: "Open Time Clock", subtitle: "Jump to the timer tab", url: SHORTCUT_URLS.openTimer },
] as const;
export function ShortcutsSetupCard() {
const { colors } = useAppTheme();
if (Platform.OS !== "ios") {
return null;
}
return (
<View style={styles.stack}>
<Text style={[styles.lead, { color: colors.mutedForeground }]}>
beenvoice actions appear when you build a shortcut they are not pre-installed in your
library. After installing a native build (not Expo Go), open the app once, then:
</Text>
<View style={[styles.steps, { borderColor: colors.border, backgroundColor: colors.muted }]}>
<Text style={[styles.step, { color: colors.foreground }]}>
1. Open the Shortcuts app tap + Add Action
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
2. Search <Text style={styles.emphasis}>beenvoice</Text> (or Clock In / Clock Out)
</Text>
<Text style={[styles.step, { color: colors.foreground }]}>
3. Pick Clock In, Clock Out, or Open Time Clock
</Text>
</View>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
You can also ask Siri: Clock in with beenvoice. Pick a client once on the Timer tab
before your first clock-in shortcut.
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
If nothing shows up, reinstall from a fresh native build (TestFlight or{" "}
<Text style={styles.emphasis}>bun run ios</Text>). Shortcuts require iOS 18+.
</Text>
<View style={styles.actions}>
{SHORTCUT_ACTIONS.map((action) => (
<Pressable
key={action.title}
accessibilityRole="button"
onPress={() => void Linking.openURL(action.url)}
style={({ pressed }) => [
styles.actionRow,
{
borderColor: colors.border,
backgroundColor: pressed ? colors.muted : "transparent",
},
]}
>
<View style={styles.actionCopy}>
<Text style={[styles.actionTitle, { color: colors.foreground }]}>{action.title}</Text>
<Text style={[styles.actionSubtitle, { color: colors.mutedForeground }]}>
{action.subtitle}
</Text>
</View>
<Ionicons name="open-outline" size={18} color={colors.mutedForeground} />
</Pressable>
))}
</View>
<Button
title="Open Shortcuts app"
variant="secondary"
onPress={() => void Linking.openURL("shortcuts://")}
/>
</View>
);
}
const styles = StyleSheet.create({
stack: {
gap: spacing.md,
},
lead: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
steps: {
borderWidth: 1,
borderRadius: 12,
gap: spacing.sm,
padding: spacing.md,
},
step: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
emphasis: {
fontFamily: fonts.bodyMedium,
},
meta: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
actions: {
gap: spacing.sm,
},
actionRow: {
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
flexDirection: "row",
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
actionCopy: {
flex: 1,
gap: 2,
},
actionTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
actionSubtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
+45
View File
@@ -0,0 +1,45 @@
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: {
width: "100%",
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
value: {
fontSize: 22,
fontFamily: fonts.heading,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
marginTop: 2,
},
});
+33
View File
@@ -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,
},
});
+5
View File
@@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}
+166
View File
@@ -0,0 +1,166 @@
import { Ionicons } from "@expo/vector-icons";
import { ReactNode, useRef } from "react";
import { Pressable, type PressableProps, StyleSheet, Text, View } from "react-native";
import Swipeable, {
type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
export type SwipeAction = {
key: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
backgroundColor: string;
onPress: () => void;
};
type SwipeableRowProps = {
children: ReactNode;
actions: SwipeAction[];
enabled?: boolean;
backgroundColor?: string;
onPress?: () => void;
onLongPress?: () => void;
contentStyle?: PressableProps["style"];
};
export function SwipeableRow({
children,
actions,
enabled = true,
backgroundColor,
onPress,
onLongPress,
contentStyle,
}: SwipeableRowProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createSwipeableRowStyles);
const rowBackground = backgroundColor ?? colors.background;
const swipeRef = useRef<SwipeableMethods>(null);
const rowOpenRef = useRef(false);
const suppressPressUntilRef = useRef(0);
function suppressContentPress() {
suppressPressUntilRef.current = Date.now() + 350;
}
function handleContentPress() {
if (!onPress) return;
if (rowOpenRef.current || Date.now() < suppressPressUntilRef.current) {
swipeRef.current?.close();
rowOpenRef.current = false;
return;
}
onPress();
}
function renderContent() {
if (!onPress && !onLongPress) {
return (
<View
style={[
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? undefined : contentStyle,
]}
>
{children}
</View>
);
}
return (
<Pressable
accessibilityRole="button"
onPress={handleContentPress}
onLongPress={onLongPress}
style={(state) => [
styles.row,
{ backgroundColor: rowBackground },
typeof contentStyle === "function" ? contentStyle(state) : contentStyle,
]}
>
{children}
</Pressable>
);
}
function renderRightActions() {
return (
<View style={styles.actions}>
{actions.map((action) => (
<Pressable
key={action.key}
style={[styles.actionButton, { backgroundColor: action.backgroundColor }]}
onPress={() => {
suppressContentPress();
swipeRef.current?.close();
rowOpenRef.current = false;
action.onPress();
}}
>
<Ionicons name={action.icon} size={20} color={action.color} />
<Text style={[styles.actionLabel, { color: action.color }]}>{action.label}</Text>
</Pressable>
))}
</View>
);
}
if (!enabled || actions.length === 0) {
return renderContent();
}
return (
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
overshootRight={false}
onSwipeableOpenStartDrag={suppressContentPress}
onSwipeableCloseStartDrag={suppressContentPress}
onSwipeableWillOpen={() => {
suppressContentPress();
rowOpenRef.current = true;
}}
onSwipeableWillClose={suppressContentPress}
onSwipeableClose={() => {
rowOpenRef.current = false;
}}
>
{renderContent()}
</Swipeable>
);
}
const createSwipeableRowStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
backgroundColor: colors.background,
borderRadius: radii.lg,
overflow: "hidden",
marginBottom: spacing.xs,
},
actions: {
flexDirection: "row",
alignItems: "stretch",
},
actionButton: {
width: 80,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
borderRadius: radii.md,
marginLeft: spacing.xs,
},
actionLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 11,
},
});
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar";
import { TopChromeBar } from "@/components/TopChromeBar";
import { useAppTheme } from "@/contexts/ThemeContext";
type TabPageProps = {
children: ReactNode;
showMoreBack?: boolean;
};
/** Tab root — pinned top chrome, scrollable body below. */
export function TabPage({ children, showMoreBack = false }: TabPageProps) {
const { isDark } = useAppTheme();
return (
<View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} />
<TopChromeBar showMoreBack={showMoreBack} />
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "transparent",
},
content: {
flex: 1,
minHeight: 0,
backgroundColor: "transparent",
},
});
+65
View File
@@ -0,0 +1,65 @@
import { useScrollToTop } from "expo-router";
import { useRef, type ReactNode } from "react";
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
import { tabLayout } from "@/lib/tab-layout";
import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
type TabScrollViewProps = ScrollViewProps & {
/** Rendered at the top of scroll content (scrolls with the page). */
header?: ReactNode;
children: ReactNode;
};
/**
* Tab screen scroll view. Top chrome (logo / account) is pinned in TabPage;
* the page header and body scroll together here.
*/
export function TabScrollView({
header,
children,
contentContainerStyle,
refreshControl,
style,
bounces,
alwaysBounceVertical,
...props
}: TabScrollViewProps) {
const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding();
const canRefresh = Boolean(refreshControl);
useScrollToTop(scrollRef);
return (
<ScrollView
ref={scrollRef}
style={[styles.scroll, style]}
contentContainerStyle={[
tabLayout.scrollContent,
{ paddingBottom: bottomPadding },
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
automaticallyAdjustContentInsets={false}
automaticallyAdjustKeyboardInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
bounces={bounces ?? canRefresh}
alwaysBounceVertical={alwaysBounceVertical ?? canRefresh}
refreshControl={refreshControl}
scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props}
>
{header}
<View style={tabLayout.scrollBody}>{children}</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
minHeight: 0,
backgroundColor: "transparent",
},
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import { useColorScheme } from './useColorScheme';
import Colors from '@/constants/Colors';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme();
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}
+76
View File
@@ -0,0 +1,76 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { StyleSheet, View } from "react-native";
import { Pressable, Text } from "react-native";
import { AccountSwitcher } from "@/components/AccountSwitcher";
import { Logo } from "@/components/Logo";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
type TopChromeProps = {
showMoreBack?: boolean;
};
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { colors, isDark } = useAppTheme();
function handleBack() {
if (router.canGoBack()) {
router.back();
return;
}
router.replace("/(app)/more" as never);
}
return (
<View style={styles.row}>
{showMoreBack ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Back to More"
onPress={handleBack}
style={({ pressed }) => [
styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
pressed && styles.pressed,
]}
>
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
</Pressable>
) : (
<Logo size="xs" onDark={isDark} />
)}
<AccountSwitcher />
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: TOP_CHROME_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
backButton: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderRadius: radii.pill,
},
backLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
},
pressed: {
opacity: 0.82,
},
});
+55
View File
@@ -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";
type TopChromeBarProps = {
showMoreBack?: boolean;
};
/** Blurred status-bar chrome with logo + account switcher. */
export function TopChromeBar({ showMoreBack = false }: TopChromeBarProps) {
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 showMoreBack={showMoreBack} />
</View>
);
}
const styles = StyleSheet.create({
host: {
flexShrink: 0,
overflow: "hidden",
},
});
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { GlassSurface } from "@/components/GlassSurface";
import { spacing } from "@/constants/theme";
const AUTH_CARD_RADIUS = 24;
type AuthCardProps = {
children: ReactNode;
style?: StyleProp<ViewStyle>;
};
export function AuthCard({ children, style }: AuthCardProps) {
return (
<GlassSurface radius={AUTH_CARD_RADIUS} style={style}>
<View style={styles.inner}>{children}</View>
</GlassSurface>
);
}
const styles = StyleSheet.create({
inner: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
gap: spacing.lg,
},
});
@@ -0,0 +1,45 @@
import { StyleSheet, Text, View } from "react-native";
import { Logo } from "@/components/Logo";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type AuthCardHeaderProps = {
title: string;
description: string;
};
export function AuthCardHeader({ title, description }: AuthCardHeaderProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<Logo size="md" />
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.description, { color: colors.mutedForeground }]}>
{description}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.md,
},
copy: {
gap: spacing.xs,
},
title: {
fontSize: 24,
fontFamily: fonts.headingSemi,
letterSpacing: -0.3,
},
description: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
},
});
@@ -0,0 +1,34 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
export function AuthDivider() {
const { colors } = useAppTheme();
return (
<View style={styles.row}>
<View style={[styles.line, { backgroundColor: colors.border }]} />
<Text style={[styles.label, { color: colors.mutedForeground }]}>or</Text>
<View style={[styles.line, { backgroundColor: colors.border }]} />
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
line: {
flex: 1,
height: StyleSheet.hairlineWidth,
},
label: {
fontSize: 12,
fontFamily: fonts.bodyMedium,
textTransform: "uppercase",
letterSpacing: 0.6,
},
});
@@ -0,0 +1,39 @@
import { StyleSheet, Text } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type AuthNoticeProps = {
children: string;
};
export function AuthNotice({ children }: AuthNoticeProps) {
const { colors } = useAppTheme();
return (
<Text
style={[
styles.notice,
{
color: colors.mutedForeground,
backgroundColor: colors.muted,
borderColor: colors.border,
},
]}
>
{children}
</Text>
);
}
const styles = StyleSheet.create({
notice: {
fontSize: 14,
fontFamily: fonts.body,
lineHeight: 20,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radii.lg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
},
});
@@ -0,0 +1,47 @@
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, type ViewProps } from "react-native";
import { AuthBackground } from "@/components/AppBackground";
import { FullScreen } from "@/components/Screen";
import { spacing } from "@/constants/theme";
export function AuthScreenLayout({ children, style, ...props }: ViewProps) {
return (
<AuthBackground>
<FullScreen style={styles.safe}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<View style={[styles.content, style]} {...props}>
{children}
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
</AuthBackground>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
flex: {
flex: 1,
},
container: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
alignSelf: "center",
},
});
@@ -0,0 +1,342 @@
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString } from "@/lib/form-validation";
import { api } from "@/lib/trpc";
type BusinessFormValues = {
name: string;
nickname: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
website: string;
taxId: string;
isDefault: boolean;
};
const emptyValues: BusinessFormValues = {
name: "",
nickname: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
website: "",
taxId: "",
isDefault: false,
};
type BusinessFormProps = {
mode: "create" | "edit";
businessId?: string;
scrollPadding: number;
onSaved: () => void;
onDeleted?: () => void;
};
export function BusinessForm({
mode,
businessId,
scrollPadding,
onSaved,
onDeleted,
}: BusinessFormProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createBusinessFormStyles);
const utils = api.useUtils();
const businessQuery = api.businesses.getById.useQuery(
{ id: businessId ?? "" },
{ enabled: mode === "edit" && Boolean(businessId) },
);
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null);
const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
ios_backgroundColor: colors.switchIosBackground,
};
useEffect(() => {
const business = businessQuery.data;
if (!business) return;
setValues({
name: business.name,
nickname: business.nickname ?? "",
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
addressLine2: business.addressLine2 ?? "",
city: business.city ?? "",
state: business.state ?? "",
postalCode: business.postalCode ?? "",
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
isDefault: business.isDefault ?? false,
});
}, [businessQuery.data]);
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const deleteBusiness = api.businesses.delete.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
onDeleted?.();
},
onError: (err) => Alert.alert("Could not delete business", err.message),
});
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null);
}
function buildPayload() {
return {
name: values.name.trim(),
nickname: values.nickname.trim(),
email: values.email.trim(),
phone: values.phone.trim(),
addressLine1: values.addressLine1.trim(),
addressLine2: values.addressLine2.trim(),
city: values.city.trim(),
state: values.state.trim(),
postalCode: values.postalCode.trim(),
country: values.country.trim() || "United States",
website: values.website.trim(),
taxId: values.taxId.trim(),
isDefault: values.isDefault,
};
}
function handleSave() {
if (!canSave) return;
const payload = buildPayload();
if (mode === "create") {
createBusiness.mutate(payload);
return;
}
if (!businessId) return;
updateBusiness.mutate({ id: businessId, ...payload });
}
function confirmDelete() {
if (!businessId) return;
Alert.alert(
"Delete business",
"This cannot be undone. Businesses with invoices cannot be deleted.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteBusiness.mutate({ id: businessId }),
},
],
);
}
const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required";
const canSave = isRequiredString(values.name);
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Profile">
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Nickname"
value={values.nickname}
onChangeText={(v) => patch("nickname", v)}
placeholder="Optional short name"
/>
<Input
label="Email"
value={values.email}
onChangeText={(v) => patch("email", v)}
keyboardType="email-address"
autoCapitalize="none"
/>
<Input
label="Phone"
value={values.phone}
onChangeText={(v) => patch("phone", v)}
keyboardType="phone-pad"
/>
<Input
label="Website"
value={values.website}
onChangeText={(v) => patch("website", v)}
autoCapitalize="none"
keyboardType="url"
placeholder="https://"
/>
<Input
label="Tax ID"
value={values.taxId}
onChangeText={(v) => patch("taxId", v)}
placeholder="Optional"
/>
<View style={styles.switchRow}>
<View style={styles.switchCopy}>
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
Default business
</Text>
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
Used for new invoices when none is selected
</Text>
</View>
<Switch
value={values.isDefault}
onValueChange={(v) => patch("isDefault", v)}
{...switchProps}
/>
</View>
</Card>
<Card title="Address">
<Input
label="Address line 1"
value={values.addressLine1}
onChangeText={(v) => patch("addressLine1", v)}
/>
<Input
label="Address line 2"
value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)}
/>
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
<Input
label="Postal code"
value={values.postalCode}
onChangeText={(v) => patch("postalCode", v)}
/>
<Input
label="Country"
value={values.country}
onChangeText={(v) => patch("country", v)}
/>
</Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}>
<Button
title={mode === "create" ? "Create business" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
<Button
title="Delete business"
variant="danger"
loading={deleteBusiness.isPending}
onPress={confirmDelete}
/>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingTop: spacing.xs,
},
switchCopy: {
flex: 1,
gap: 2,
},
switchLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
switchHint: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
actions: {
gap: spacing.sm,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
@@ -0,0 +1,301 @@
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
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 type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, parseNonNegativeNumber } from "@/lib/form-validation";
import { api } from "@/lib/trpc";
export type ClientFormValues = {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
defaultHourlyRate: string;
currency: string;
};
const emptyValues: ClientFormValues = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
defaultHourlyRate: "",
currency: "USD",
};
type ClientFormProps = {
mode: "create" | "edit";
clientId?: string;
scrollPadding: number;
onSaved: () => void;
onDeleted?: () => void;
};
export function ClientForm({
mode,
clientId,
scrollPadding,
onSaved,
onDeleted,
}: ClientFormProps) {
const styles = useThemedStyles(createClientFormStyles);
const utils = api.useUtils();
const clientQuery = api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: mode === "edit" && Boolean(clientId) },
);
const [values, setValues] = useState<ClientFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null);
useEffect(() => {
const client = clientQuery.data;
if (!client) return;
setValues({
name: client.name,
email: client.email ?? "",
phone: client.phone ?? "",
addressLine1: client.addressLine1 ?? "",
addressLine2: client.addressLine2 ?? "",
city: client.city ?? "",
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "United States",
defaultHourlyRate:
client.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "",
currency: client.currency ?? "USD",
});
}, [clientQuery.data]);
const createClient = api.clients.create.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const updateClient = api.clients.update.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
if (clientId) void utils.clients.getById.invalidate({ id: clientId });
void utils.dashboard.getStats.invalidate();
onSaved();
},
onError: (err) => setFieldError(err.message),
});
const deleteClient = api.clients.delete.useMutation({
onSuccess: () => {
void utils.clients.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
onDeleted?.();
},
onError: (err) => Alert.alert("Could not delete client", err.message),
});
function patch(field: keyof ClientFormValues, value: string) {
setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null);
}
function handleSave() {
if (!canSave) return;
const rate = values.defaultHourlyRate.trim()
? Number(values.defaultHourlyRate)
: undefined;
if (rate !== undefined && (Number.isNaN(rate) || rate < 0)) {
setFieldError("Hourly rate must be a valid number");
return;
}
const payload = {
name: values.name.trim(),
email: values.email.trim(),
phone: values.phone.trim(),
addressLine1: values.addressLine1.trim(),
addressLine2: values.addressLine2.trim(),
city: values.city.trim(),
state: values.state.trim(),
postalCode: values.postalCode.trim(),
country: values.country.trim() || "United States",
defaultHourlyRate: rate,
currency: values.currency.trim() || "USD",
};
if (mode === "create") {
createClient.mutate(payload);
return;
}
if (!clientId) return;
updateClient.mutate({ id: clientId, ...payload });
}
function confirmDelete() {
if (!clientId) return;
Alert.alert(
"Delete client",
"This cannot be undone. Clients with invoices cannot be deleted.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteClient.mutate({ id: clientId }),
},
],
);
}
const saving = createClient.isPending || updateClient.isPending;
const nameError = values.name.trim() ? undefined : "Name is required";
const rateError =
values.defaultHourlyRate.trim() && parseNonNegativeNumber(values.defaultHourlyRate) === null
? "Hourly rate must be a valid number"
: undefined;
const canSave = isRequiredString(values.name) && !rateError;
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
<Card title="Contact">
<Input
label="Name"
value={values.name}
onChangeText={(v) => patch("name", v)}
required
error={nameError}
/>
<Input
label="Email"
value={values.email}
onChangeText={(v) => patch("email", v)}
keyboardType="email-address"
autoCapitalize="none"
/>
<Input
label="Phone"
value={values.phone}
onChangeText={(v) => patch("phone", v)}
keyboardType="phone-pad"
/>
</Card>
<Card title="Address">
<Input
label="Address line 1"
value={values.addressLine1}
onChangeText={(v) => patch("addressLine1", v)}
/>
<Input
label="Address line 2"
value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)}
/>
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
<Input
label="Postal code"
value={values.postalCode}
onChangeText={(v) => patch("postalCode", v)}
/>
<Input
label="Country"
value={values.country}
onChangeText={(v) => patch("country", v)}
/>
</Card>
<Card title="Billing">
<Input
label="Default hourly rate"
value={values.defaultHourlyRate}
onChangeText={(v) => patch("defaultHourlyRate", v)}
keyboardType="decimal-pad"
placeholder="Optional"
error={rateError}
/>
<Input
label="Currency"
value={values.currency}
onChangeText={(v) => patch("currency", v.toUpperCase())}
autoCapitalize="characters"
maxLength={3}
/>
</Card>
{fieldError ? <Text style={styles.error}>{fieldError}</Text> : null}
<View style={styles.actions}>
<Button
title={mode === "create" ? "Create client" : "Save changes"}
loading={saving}
disabled={!canSave}
onPress={handleSave}
/>
{mode === "edit" ? (
<Button
title="Delete client"
variant="danger"
loading={deleteClient.isPending}
onPress={confirmDelete}
/>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createClientFormStyles = (colors: ThemeColors, _isDark: boolean) =>
StyleSheet.create({
flex: { flex: 1 },
container: {
padding: spacing.md,
gap: spacing.md,
},
actions: {
gap: spacing.sm,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
fontSize: 14,
},
});
@@ -0,0 +1,227 @@
import { Switch, StyleSheet, Text, View } from "react-native";
import { DateTimeField } from "@/components/ui/DateTimeField";
import { Input } from "@/components/ui/Input";
import { SelectField, type SelectOption } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
const NONE = "__none__";
export type ExpenseFormState = {
description: string;
amountText: string;
date: Date;
category: string;
businessId: string;
clientId: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
};
type ExpenseFormFieldsProps = {
value: ExpenseFormState;
businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
clients: Array<{ id: string; name: string }>;
onChange: (value: ExpenseFormState) => void;
notesLabel?: string;
notesPlaceholder?: string;
};
export function defaultExpenseFormState(
defaultBusinessId = "",
): ExpenseFormState {
return {
description: "",
amountText: "",
date: new Date(),
category: "",
businessId: defaultBusinessId,
clientId: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
};
}
export function ExpenseFormFields({
value,
businesses,
clients,
onChange,
notesLabel = "Notes",
notesPlaceholder = "Internal details",
}: ExpenseFormFieldsProps) {
const { colors } = useAppTheme();
const businessOptions: SelectOption[] = [
{ label: "Default business", value: NONE },
...businesses.map((business) => ({
label: business.isDefault ? `${business.name} (default)` : business.name,
value: business.id,
})),
];
const clientOptions: SelectOption[] = [
{ label: "No client", value: NONE },
...clients.map((client) => ({ label: client.name, value: client.id })),
];
const categoryOptions: SelectOption[] = [
{ label: "No category", value: NONE },
...EXPENSE_CATEGORIES.map((category) => ({
label: category,
value: category,
})),
];
const setField = <K extends keyof ExpenseFormState>(
field: K,
nextValue: ExpenseFormState[K],
) => onChange({ ...value, [field]: nextValue });
return (
<View style={styles.stack}>
<Input
label="Description"
required
value={value.description}
onChangeText={(text) => setField("description", text)}
placeholder="e.g. Client lunch"
/>
<Input
label="Amount"
required
value={value.amountText}
onChangeText={(text) => setField("amountText", text)}
keyboardType="decimal-pad"
placeholder="0.00"
/>
<DateTimeField
label="Date"
value={value.date}
onChange={(date) => setField("date", date)}
mode="date"
/>
<SelectField
label="Category"
placeholder="No category"
value={value.category || NONE}
options={categoryOptions}
onValueChange={(next) =>
setField("category", next === NONE ? "" : next)
}
/>
<SelectField
label="Business"
placeholder="Default business"
value={value.businessId || NONE}
options={businessOptions}
onValueChange={(next) =>
setField("businessId", next === NONE ? "" : next)
}
/>
<SelectField
label="Client"
placeholder="No client"
value={value.clientId || NONE}
options={clientOptions}
onValueChange={(next) =>
setField("clientId", next === NONE ? "" : next)
}
/>
<View style={styles.flags}>
<FlagSwitch
label="Billable"
value={value.billable}
onValueChange={(next) => setField("billable", next)}
/>
<FlagSwitch
label="Reimbursable"
value={value.reimbursable}
onValueChange={(next) => setField("reimbursable", next)}
/>
<FlagSwitch
label="Tax deductible"
value={value.taxDeductible}
onValueChange={(next) => setField("taxDeductible", next)}
/>
</View>
<Input
label={notesLabel}
value={value.notes}
onChangeText={(text) => setField("notes", text)}
placeholder={notesPlaceholder}
multiline
style={styles.notesInput}
/>
</View>
);
function FlagSwitch({
label,
value: checked,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (value: boolean) => void;
}) {
return (
<View
style={[
styles.flagRow,
{
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
]}
>
<Text style={[styles.flagLabel, { color: colors.foreground }]}>
{label}
</Text>
<Switch
value={checked}
onValueChange={onValueChange}
trackColor={{
true: colors.switchTrackOn,
false: colors.switchTrackOff,
}}
thumbColor={colors.switchThumb}
ios_backgroundColor={colors.switchIosBackground}
/>
</View>
);
}
}
const styles = StyleSheet.create({
stack: {
gap: spacing.md,
},
flags: {
gap: spacing.sm,
},
flagRow: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
flagLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
notesInput: {
minHeight: 96,
textAlignVertical: "top",
paddingTop: spacing.md,
},
});
@@ -0,0 +1,320 @@
import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency } from "@/lib/format";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSplitSelection = {
selectedItemIds: string[];
selectedSubtotal: number;
allocatedTax: number;
owedTotal: number;
notes: string;
};
type ReceiptItemSelectorProps = {
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
onApply: (selection: ReceiptSplitSelection) => void;
};
export function ReceiptItemSelector({
items,
subtotal,
tax,
total,
onApply,
}: ReceiptItemSelectorProps) {
const { colors } = useAppTheme();
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(items.length === 1 ? [items[0]!.id] : []),
);
const calculation = useMemo(() => {
const selected = items.filter((item) => selectedIds.has(item.id));
const selectedSubtotal = roundMoney(
selected.reduce((sum, item) => sum + item.amount, 0),
);
const receiptSubtotal =
subtotal && subtotal > 0
? subtotal
: roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
const knownTax =
tax ??
(total && receiptSubtotal > 0
? Math.max(0, roundMoney(total - receiptSubtotal))
: 0);
const allocatedTax =
receiptSubtotal > 0
? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
: 0;
const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
const notes = [
"Receipt split",
...selected.map(
(item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
),
`Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
`Allocated tax: ${formatCurrency(allocatedTax)}`,
`Owed total: ${formatCurrency(owedTotal)}`,
].join("\n");
return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
}, [items, selectedIds, subtotal, tax, total]);
if (items.length === 0) return null;
const toggle = (id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<View
style={[
styles.wrap,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.title, { color: colors.foreground }]}>
Select expense items
</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Pick only the receipt lines that should become this expense. Tax is
split proportionally.
</Text>
</View>
<Text style={[styles.total, { color: colors.foreground }]}>
{formatCurrency(calculation.owedTotal)}
</Text>
</View>
{items.length > 1 ? (
<View style={styles.bulkActions}>
<Pressable
accessibilityRole="button"
onPress={() =>
setSelectedIds(new Set(items.map((item) => item.id)))
}
style={({ pressed }) => [
styles.bulkAction,
pressed && styles.pressed,
]}
>
<Text style={[styles.bulkActionText, { color: colors.primary }]}>
Select all
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => setSelectedIds(new Set())}
style={({ pressed }) => [
styles.bulkAction,
pressed && styles.pressed,
]}
>
<Text
style={[styles.bulkActionText, { color: colors.mutedForeground }]}
>
Clear
</Text>
</Pressable>
</View>
) : null}
<View style={styles.itemList}>
{items.map((item) => {
const selected = selectedIds.has(item.id);
return (
<Pressable
key={item.id}
accessibilityRole="checkbox"
accessibilityState={{ checked: selected }}
onPress={() => toggle(item.id)}
style={({ pressed }) => [
styles.item,
{ borderColor: colors.borderGlass, backgroundColor: colors.background },
selected && { backgroundColor: colors.muted },
pressed && styles.pressed,
]}
>
<Ionicons
name={selected ? "checkmark-circle" : "ellipse-outline"}
size={21}
color={selected ? colors.primary : colors.mutedForeground}
/>
<Text
style={[styles.itemName, { color: colors.foreground }]}
numberOfLines={2}
>
{item.name}
</Text>
<Text style={[styles.itemAmount, { color: colors.foreground }]}>
{formatCurrency(item.amount)}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.summary}>
<SummaryRow label="Items" value={calculation.selectedSubtotal} />
<SummaryRow label="Tax" value={calculation.allocatedTax} />
<SummaryRow label="Owed" value={calculation.owedTotal} strong />
</View>
<Button
title={
calculation.selected.length === 0
? "Select an item to apply"
: "Apply selected items"
}
disabled={calculation.selected.length === 0}
onPress={() =>
onApply({
selectedItemIds: calculation.selected.map((item) => item.id),
selectedSubtotal: calculation.selectedSubtotal,
allocatedTax: calculation.allocatedTax,
owedTotal: calculation.owedTotal,
notes: calculation.notes,
})
}
/>
</View>
);
function SummaryRow({
label,
value,
strong,
}: {
label: string;
value: number;
strong?: boolean;
}) {
return (
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
<Text
style={[
styles.summaryValue,
{ color: colors.foreground },
strong && styles.summaryValueStrong,
]}
>
{formatCurrency(value)}
</Text>
</View>
);
}
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
const styles = StyleSheet.create({
wrap: {
gap: spacing.md,
borderWidth: 1,
borderRadius: 16,
padding: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: spacing.md,
},
headerCopy: {
flex: 1,
gap: 2,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
total: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
fontVariant: ["tabular-nums"],
},
itemList: {
gap: spacing.sm,
},
bulkActions: {
flexDirection: "row",
gap: spacing.sm,
},
bulkAction: {
minHeight: 34,
justifyContent: "center",
},
bulkActionText: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
item: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
pressed: {
opacity: 0.85,
},
itemName: {
flex: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
},
itemAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
fontVariant: ["tabular-nums"],
},
summary: {
gap: spacing.xs,
},
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
},
summaryLabel: {
fontFamily: fonts.body,
fontSize: 13,
},
summaryValue: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
fontVariant: ["tabular-nums"],
},
summaryValueStrong: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
});
@@ -0,0 +1,155 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import type { InvoiceStatus } from "@/lib/invoice-status";
type ActionItem = {
key: string;
title: string;
subtitle?: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
loading?: boolean;
};
type InvoiceDetailActionsProps = {
status: InvoiceStatus;
clientEmail: string;
onPaymentReminder?: () => void;
paymentReminderLoading?: boolean;
onUpdateStatus: () => void;
updateStatusLoading?: boolean;
onTrackTime: () => void;
};
export function InvoiceDetailActions({
status,
clientEmail,
onPaymentReminder,
paymentReminderLoading,
onUpdateStatus,
updateStatusLoading,
onTrackTime,
}: InvoiceDetailActionsProps) {
const { colors } = useAppTheme();
const rows: ActionItem[] = [];
if ((status === "sent" || status === "overdue") && onPaymentReminder) {
rows.push({
key: "reminder",
title: "Send payment reminder",
subtitle: clientEmail ? `Nudge ${clientEmail}` : "Add a client email first",
icon: "notifications-outline",
onPress: onPaymentReminder,
loading: paymentReminderLoading,
});
}
rows.push(
{
key: "status",
title: "Update status",
subtitle: "Draft, sent, or paid",
icon: "swap-horizontal-outline",
onPress: onUpdateStatus,
loading: updateStatusLoading,
},
{
key: "timer",
title: "Track time",
subtitle: "Clock hours to this invoice",
icon: "timer-outline",
onPress: onTrackTime,
},
);
return (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<View style={styles.list}>
{rows.map((row, index) => (
<View key={row.key}>
{index > 0 ? (
<View style={[styles.divider, { backgroundColor: colors.border }]} />
) : null}
<Pressable
accessibilityRole="button"
disabled={row.loading}
onPress={row.onPress}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={row.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, { color: colors.foreground }]}>{row.title}</Text>
{row.subtitle ? (
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
{row.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: radii.lg,
padding: spacing.md,
gap: spacing.sm,
},
list: {
gap: 0,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: spacing.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingVertical: spacing.sm,
},
rowPressed: {
opacity: 0.75,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
copy: {
flex: 1,
gap: 2,
minWidth: 0,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
subtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
@@ -0,0 +1,129 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type SecondaryAction = {
title: string;
subtitle?: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
loading?: boolean;
disabled?: boolean;
};
type InvoiceEditorFooterProps = {
primaryTitle: string;
onPrimary: () => void;
primaryLoading?: boolean;
primaryDisabled?: boolean;
secondary?: SecondaryAction;
};
export function InvoiceEditorFooter({
primaryTitle,
onPrimary,
primaryLoading,
primaryDisabled,
secondary,
}: InvoiceEditorFooterProps) {
const { colors } = useAppTheme();
return (
<View
style={[
styles.card,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
>
<Button
title={primaryTitle}
loading={primaryLoading}
disabled={primaryDisabled}
onPress={onPrimary}
/>
{secondary ? (
<>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<Pressable
accessibilityRole="button"
disabled={secondary.disabled || secondary.loading}
onPress={secondary.onPress}
style={({ pressed }) => [
styles.secondaryRow,
(pressed || secondary.loading) && styles.secondaryPressed,
(secondary.disabled || secondary.loading) && styles.secondaryDisabled,
]}
>
<View style={[styles.iconWrap, { backgroundColor: colors.muted }]}>
<Ionicons name={secondary.icon} size={20} color={colors.foreground} />
</View>
<View style={styles.secondaryCopy}>
<Text style={[styles.secondaryTitle, { color: colors.foreground }]}>
{secondary.title}
</Text>
{secondary.subtitle ? (
<Text style={[styles.secondarySubtitle, { color: colors.mutedForeground }]}>
{secondary.subtitle}
</Text>
) : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.mutedForeground} />
</Pressable>
</>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: radii.lg,
padding: spacing.md,
gap: spacing.sm,
},
divider: {
height: StyleSheet.hairlineWidth,
marginVertical: spacing.xs,
},
secondaryRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
secondaryPressed: {
opacity: 0.75,
},
secondaryDisabled: {
opacity: 0.45,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
},
secondaryCopy: {
flex: 1,
gap: 2,
minWidth: 0,
},
secondaryTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
secondarySubtitle: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
});
@@ -0,0 +1,60 @@
import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
export type InvoiceEditorSection = "setup" | "lines" | "preview";
export type InvoiceViewSection = "details" | "preview";
type InvoiceEditorSectionTabsProps =
| {
mode?: "edit";
value: InvoiceEditorSection;
onChange: (value: InvoiceEditorSection) => void;
}
| {
mode: "view";
value: InvoiceViewSection;
onChange: (value: InvoiceViewSection) => void;
};
export function InvoiceEditorSectionTabs(props: InvoiceEditorSectionTabsProps) {
const tabs =
props.mode === "view"
? [
{ id: "details" as const, label: "Details" },
{ id: "preview" as const, label: "PDF" },
]
: [
{ id: "setup" as const, label: "Setup" },
{ id: "lines" as const, label: "Line items" },
{ id: "preview" as const, label: "PDF preview" },
];
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{tabs.map((tab) => (
<FilterChip
key={tab.id}
label={tab.label}
active={props.value === tab.id}
onPress={() => props.onChange(tab.id as never)}
/>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
});
@@ -0,0 +1,180 @@
import { useMemo } from "react";
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView } from "react-native-webview";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import {
canPreviewPdfInput,
type InvoicePdfPreviewInput,
} from "@/lib/invoice-pdf-input";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type InvoicePdfPreviewProps = {
input: InvoicePdfPreviewInput | null;
height?: number;
style?: StyleProp<ViewStyle>;
};
function buildPdfHtml(contentType: string, base64: string) {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
<style>
html, body { margin: 0; height: 100%; background: #525659; }
embed { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
</body>
</html>`;
}
export function InvoicePdfPreview({
input,
height = 560,
style,
}: InvoicePdfPreviewProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createPreviewStyles);
const enabled = canPreviewPdfInput(input);
const { data, isLoading, isFetching, error, refetch } =
api.invoices.previewPdf.useQuery(input!, {
enabled,
refetchOnWindowFocus: false,
staleTime: 5_000,
});
const html = useMemo(() => {
if (!data?.base64) return null;
return buildPdfHtml(data.contentType, data.base64);
}, [data]);
if (!enabled) {
return (
<View style={[styles.frame, { height }, style]}>
<Text style={styles.placeholder}>
Select a client and add a description to every line item to preview the
PDF.
</Text>
</View>
);
}
if (isLoading && !html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>Generating preview</Text>
</View>
);
}
if (error) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.errorText}>{error.message}</Text>
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
</Pressable>
</View>
);
}
if (!html) {
return (
<View style={[styles.frame, styles.centered, { height }, style]}>
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
</View>
);
}
return (
<View style={[styles.wrapper, style]}>
{isFetching ? (
<View style={styles.refreshing}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null}
<View style={[styles.frame, { height }]}>
<WebView
originWhitelist={["*"]}
source={{ html }}
style={styles.webview}
scrollEnabled
showsVerticalScrollIndicator
showsHorizontalScrollIndicator={false}
/>
</View>
</View>
);
}
const createPreviewStyles = (colors: ThemeColors) =>
StyleSheet.create({
wrapper: {
gap: spacing.xs,
},
frame: {
overflow: "hidden",
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
webview: {
flex: 1,
backgroundColor: "transparent",
},
centered: {
alignItems: "center",
justifyContent: "center",
padding: spacing.lg,
gap: spacing.sm,
},
placeholder: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
textAlign: "center",
padding: spacing.lg,
},
loadingText: {
fontFamily: fonts.body,
fontSize: 13,
color: colors.mutedForeground,
},
errorText: {
fontFamily: fonts.body,
fontSize: 14,
color: colors.destructive,
textAlign: "center",
},
retry: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
refreshing: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
zIndex: 2,
borderRadius: radii.pill,
backgroundColor: colors.card,
padding: spacing.xs,
},
});
@@ -0,0 +1,229 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
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 { defaultDueDate } from "@/lib/invoice-number";
type SelectOption = { label: string; value: string };
type InvoiceSetupFormProps = {
businessId: string;
onBusinessIdChange: (value: string) => void;
businessOptions: SelectOption[];
businessError?: string;
businessReadOnly?: boolean;
clientId: string;
onClientIdChange: (value: string) => void;
clientOptions: SelectOption[];
clientError?: string;
clientReadOnly?: boolean;
invoiceNumber: string;
onInvoiceNumberChange?: (value: string) => void;
invoiceNumberReadOnly?: boolean;
issueDate: Date;
onIssueDateChange?: (date: Date) => void;
issueDateReadOnly?: boolean;
dueDate: Date;
onDueDateChange: (date: Date) => void;
taxRate: string;
onTaxRateChange?: (value: string) => void;
taxRateReadOnly?: boolean;
notes: string;
onNotesChange: (value: string) => void;
sendReminderAt?: Date | null;
onSendReminderAtChange?: (date: Date | null) => void;
showSendReminder?: boolean;
};
export function InvoiceSetupForm({
businessId,
onBusinessIdChange,
businessOptions,
businessError,
businessReadOnly = false,
clientId,
onClientIdChange,
clientOptions,
clientError,
clientReadOnly = false,
invoiceNumber,
onInvoiceNumberChange,
invoiceNumberReadOnly = false,
issueDate,
onIssueDateChange,
issueDateReadOnly = false,
dueDate,
onDueDateChange,
taxRate,
onTaxRateChange,
taxRateReadOnly = false,
notes,
onNotesChange,
sendReminderAt,
onSendReminderAtChange,
showSendReminder = false,
}: InvoiceSetupFormProps) {
const { colors } = useAppTheme();
return (
<View style={styles.form}>
{businessOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a business in Entities before invoicing.
</Text>
) : (
<SelectField
label="Business"
placeholder="Select business…"
value={businessId}
options={businessOptions}
required
error={businessError}
disabled={businessReadOnly}
onValueChange={onBusinessIdChange}
/>
)}
{clientOptions.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add a client in Entities before invoicing.
</Text>
) : (
<SelectField
label="Client"
placeholder="Select client…"
value={clientId}
options={clientOptions}
required
error={clientError}
disabled={clientReadOnly}
onValueChange={onClientIdChange}
/>
)}
{invoiceNumberReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Invoice number
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{invoiceNumber}
</Text>
</View>
) : (
<Input
label="Invoice number"
value={invoiceNumber}
onChangeText={onInvoiceNumberChange}
autoCapitalize="characters"
required
/>
)}
{issueDateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Issue date
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{issueDate.toLocaleDateString()}
</Text>
</View>
) : (
<DateTimeField
label="Issue date"
mode="date"
value={issueDate}
onChange={(date) => {
onIssueDateChange?.(date);
if (dueDate < date) onDueDateChange(defaultDueDate(date));
}}
/>
)}
<DateTimeField label="Due date" mode="date" value={dueDate} onChange={onDueDateChange} />
{taxRateReadOnly ? (
<View style={styles.readOnlyField}>
<Text style={[styles.readOnlyLabel, { color: colors.mutedForeground }]}>
Tax rate
</Text>
<Text style={[styles.readOnlyValue, { color: colors.foreground }]}>
{taxRate}%
</Text>
</View>
) : (
<Input
label="Tax rate (%)"
value={taxRate}
onChangeText={onTaxRateChange}
keyboardType="decimal-pad"
/>
)}
{showSendReminder && onSendReminderAtChange ? (
<>
<DateTimeField
label="Remind me to send"
mode="date"
value={sendReminderAt ?? dueDate}
minimumDate={new Date()}
maximumDate={new Date(2100, 0, 1)}
onChange={onSendReminderAtChange}
/>
{sendReminderAt ? (
<Pressable onPress={() => onSendReminderAtChange(null)}>
<Text style={[styles.clearReminder, { color: colors.primary }]}>
Clear send reminder
</Text>
</Pressable>
) : null}
</>
) : null}
<Input
label="Notes"
value={notes}
onChangeText={onNotesChange}
placeholder="Optional notes for the client"
multiline
style={styles.notesInput}
/>
</View>
);
}
const styles = StyleSheet.create({
form: {
gap: spacing.sm,
},
hint: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
readOnlyField: {
gap: 4,
paddingVertical: 4,
},
readOnlyLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
},
readOnlyValue: {
fontFamily: fonts.body,
fontSize: 15,
},
notesInput: {
minHeight: 72,
textAlignVertical: "top",
},
clearReminder: {
fontFamily: fonts.bodyMedium,
fontSize: 13,
marginBottom: spacing.xs,
},
});
@@ -0,0 +1,90 @@
import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type InvoiceTotalsProps = {
subtotal: string;
taxLabel?: string;
taxAmount?: string;
total: string;
};
export function InvoiceTotals({
subtotal,
taxLabel,
taxAmount,
total,
}: InvoiceTotalsProps) {
const { colors } = useAppTheme();
return (
<View style={[styles.totals, { borderTopColor: colors.border }]}>
<TotalRow label="Subtotal" value={subtotal} />
{taxLabel && taxAmount ? <TotalRow label={taxLabel} value={taxAmount} /> : null}
<TotalRow label="Total" value={total} bold />
</View>
);
}
function TotalRow({
label,
value,
bold,
}: {
label: string;
value: string;
bold?: boolean;
}) {
const { colors } = useAppTheme();
return (
<View style={styles.row}>
<Text
style={[
styles.label,
{ color: colors.mutedForeground },
bold && styles.bold,
bold && { color: colors.foreground },
]}
>
{label}
</Text>
<Text
style={[
styles.value,
{ color: colors.foreground },
bold && styles.bold,
]}
>
{value}
</Text>
</View>
);
}
const styles = StyleSheet.create({
totals: {
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
gap: spacing.xs,
},
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,
},
});
@@ -0,0 +1,58 @@
import { ScrollView, StyleSheet, View } from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { spacing } from "@/constants/theme";
import type { InvoiceStatus } from "@/lib/invoice-status";
export type InvoiceViewSection = "details" | "preview";
type InvoiceViewChipsProps = {
section: InvoiceViewSection;
onSectionChange: (section: InvoiceViewSection) => void;
status: InvoiceStatus;
onEdit: () => void;
onSend: () => void;
};
export function InvoiceViewChips({
section,
onSectionChange,
status,
onEdit,
onSend,
}: InvoiceViewChipsProps) {
const sendLabel = status === "draft" ? "Send" : "Resend";
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
<FilterChip
label="Details"
active={section === "details"}
onPress={() => onSectionChange("details")}
/>
<FilterChip label="Edit" onPress={onEdit} />
{status !== "paid" ? (
<FilterChip label={sendLabel} onPress={onSend} />
) : null}
<FilterChip
label="View PDF"
active={section === "preview"}
onPress={() => onSectionChange("preview")}
/>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: spacing.sm,
paddingVertical: spacing.xs,
},
});
@@ -0,0 +1,310 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { CompactDateField } from "@/components/ui/CompactDateField";
import { CompactStepperInput } from "@/components/ui/CompactStepperInput";
import { SwipeableRow } from "@/components/SwipeableRow";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatShortDate } from "@/lib/format";
export type EditableLineItem = {
id?: string;
date: Date;
description: string;
hours: string;
rate: string;
};
type LineItemEditorProps = {
item: EditableLineItem;
index: number;
currency: string;
onChange: (patch: Partial<EditableLineItem>) => void;
onRemove: () => void;
onDuplicate?: () => void;
readOnly?: boolean;
isLast?: boolean;
};
function FieldLabel({ children }: { children: string }) {
const { colors } = useAppTheme();
return (
<Text style={[styles.fieldLabel, { color: colors.mutedForeground }]}>{children}</Text>
);
}
export function LineItemEditor({
item,
index,
currency,
onChange,
onRemove,
onDuplicate,
readOnly = false,
isLast = false,
}: LineItemEditorProps) {
const { colors } = useAppTheme();
const hours = Number(item.hours) || 0;
const rate = Number(item.rate) || 0;
const amount = hours * rate;
if (readOnly) {
return (
<View
style={[
styles.readBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]}
>
<Text style={[styles.readIndex, { color: colors.mutedForeground }]}>
Line {index + 1}
</Text>
<Text style={[styles.readTitle, { color: colors.foreground }]} numberOfLines={3}>
{item.description.trim() || "Untitled line"}
</Text>
<Text style={[styles.readSub, { color: colors.mutedForeground }]}>
{formatShortDate(item.date)} · {hours}h × {formatCurrency(rate, currency)}
</Text>
<Text style={[styles.readAmount, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
</View>
);
}
const content = (
<View
style={[
styles.editBlock,
!isLast && { borderBottomColor: colors.border, borderBottomWidth: StyleSheet.hairlineWidth },
]}
>
<Text style={[styles.lineLabel, { color: colors.mutedForeground }]}>Line {index + 1}</Text>
<TextInput
value={item.description}
onChangeText={(description) => onChange({ description })}
placeholder="What was done?"
placeholderTextColor={colors.mutedForeground}
style={[
styles.descriptionInput,
{
color: colors.foreground,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
]}
/>
<View style={styles.fieldsRow}>
<View style={styles.fieldCol}>
<FieldLabel>Date</FieldLabel>
<CompactDateField
value={item.date}
onChange={(date) => onChange({ date })}
style={styles.fieldControl}
/>
</View>
<View style={styles.fieldCol}>
<FieldLabel>Hours</FieldLabel>
<CompactStepperInput
value={item.hours}
onChangeText={(hours) => onChange({ hours })}
step={0.25}
style={styles.fieldControl}
/>
</View>
<View style={styles.fieldCol}>
<FieldLabel>Rate</FieldLabel>
<View
style={[
styles.rateField,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Text style={[styles.ratePrefix, { color: colors.mutedForeground }]}>$</Text>
<TextInput
value={item.rate}
onChangeText={(rate) => onChange({ rate })}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.rateInput, { color: colors.foreground }]}
/>
</View>
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.amountGroup}>
<Text style={[styles.amountLabel, { color: colors.mutedForeground }]}>Amount</Text>
<Text style={[styles.amountValue, { color: colors.foreground }]}>
{formatCurrency(amount, currency)}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Remove line item"
onPress={onRemove}
hitSlop={8}
style={({ pressed }) => [
styles.remove,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
pressed && styles.removePressed,
]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</View>
);
const swipeActions = [
...(onDuplicate
? [
{
key: "duplicate",
label: "Copy",
icon: "copy-outline" as const,
color: "#fff",
backgroundColor: colors.primary,
onPress: onDuplicate,
},
]
: []),
{
key: "delete",
label: "Delete",
icon: "trash-outline" as const,
color: "#fff",
backgroundColor: colors.destructive,
onPress: onRemove,
},
];
return (
<SwipeableRow actions={swipeActions} backgroundColor={colors.card}>
{content}
</SwipeableRow>
);
}
const styles = StyleSheet.create({
readBlock: {
paddingVertical: spacing.md,
gap: spacing.xs,
},
readIndex: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
},
readTitle: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
lineHeight: 20,
},
readSub: {
fontFamily: fonts.body,
fontSize: 13,
},
readAmount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
marginTop: 2,
},
editBlock: {
paddingVertical: spacing.md,
gap: spacing.sm,
},
lineLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.4,
},
descriptionInput: {
width: "100%",
minHeight: 40,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.sm,
fontFamily: fonts.body,
fontSize: 15,
paddingVertical: 8,
},
fieldsRow: {
flexDirection: "row",
gap: spacing.sm,
},
fieldCol: {
flex: 1,
gap: 4,
minWidth: 0,
},
fieldLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: 0.3,
},
fieldControl: {
width: "100%",
},
rateField: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.xs,
},
ratePrefix: {
fontFamily: fonts.body,
fontSize: 13,
},
rateInput: {
flex: 1,
fontFamily: fonts.bodyMedium,
fontSize: 13,
paddingVertical: 4,
textAlign: "right",
minWidth: 0,
},
footerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
marginTop: 2,
},
amountGroup: {
flex: 1,
flexDirection: "row",
alignItems: "baseline",
gap: spacing.sm,
},
amountLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.3,
},
amountValue: {
fontFamily: fonts.bodySemiBold,
fontSize: 17,
},
remove: {
width: 40,
height: 40,
borderRadius: radii.md,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
removePressed: {
opacity: 0.65,
},
});
@@ -0,0 +1,53 @@
import * as WebBrowser from "expo-web-browser";
import { StyleSheet, Text } from "react-native";
import { fonts } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
type LegalAgreementNoticeProps = {
action: string;
};
function openLegalPage(baseUrl: string, path: "/terms" | "/privacy") {
const origin = baseUrl.replace(/\/$/, "");
void WebBrowser.openBrowserAsync(`${origin}${path}`);
}
export function LegalAgreementNotice({ action }: LegalAgreementNoticeProps) {
const { colors } = useAppTheme();
const { apiUrl } = useAccounts();
return (
<Text style={[styles.text, { color: colors.mutedForeground }]}>
By {action}, you agree to our{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/terms")}
>
Terms of Service
</Text>{" "}
and{" "}
<Text
style={[styles.link, { color: colors.foreground }]}
onPress={() => openLegalPage(apiUrl, "/privacy")}
>
Privacy Policy
</Text>
.
</Text>
);
}
const styles = StyleSheet.create({
text: {
textAlign: "center",
fontSize: 12,
fontFamily: fonts.body,
lineHeight: 18,
},
link: {
fontFamily: fonts.bodyMedium,
textDecorationLine: "underline",
},
});
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import {
endTimeClockLiveActivity,
syncTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import { api } from "@/lib/trpc";
/** Keeps the iOS Live Activity in sync while a timer runs — app-wide, not just on the Timer tab. */
export function TimeClockLiveActivitySync() {
const runningQuery = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 60_000,
});
const running = runningQuery.data;
useEffect(() => {
if (!running) {
void endTimeClockLiveActivity();
return;
}
const sync = () => {
const seconds = Math.floor(
(Date.now() - new Date(running.startedAt).getTime()) / 1000,
);
void syncTimeClockLiveActivity(running, seconds);
};
sync();
const interval = setInterval(sync, 60_000);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") sync();
});
return () => {
clearInterval(interval);
subscription.remove();
};
}, [running]);
return null;
}
@@ -0,0 +1,999 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Alert,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { router } from "expo-router";
import { FilterChip } from "@/components/FilterChip";
import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabScrollView } from "@/components/TabScrollView";
import { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
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 { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import {
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
import { useThemedStyles } from "@/lib/use-themed-styles";
import {
endTimeClockLiveActivity,
} from "@/lib/time-clock-live-activity";
import {
DEFAULT_CLOCK_DESCRIPTION,
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "@/lib/time-clock";
import { useRunningElapsed } from "@/lib/use-running-elapsed";
import { api } from "@/lib/trpc";
export type TimeClockPanelProps = {
defaultClientId?: string;
defaultInvoiceId?: string;
compact?: boolean;
header?: ReactNode;
};
type ClientRow = {
id: string;
name: string;
defaultHourlyRate: number | null;
currency?: string;
};
type StartMode = "now" | "at" | "ago";
const AGO_PRESETS = [
{ label: "15m", minutes: 15 },
{ label: "30m", minutes: 30 },
{ label: "1h", minutes: 60 },
{ label: "2h", minutes: 120 },
{ label: "4h", minutes: 240 },
] as const;
function clientRateText(client: ClientRow | undefined): string {
return client?.defaultHourlyRate != null ? String(client.defaultHourlyRate) : "";
}
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
compact = false,
header,
}: TimeClockPanelProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createTimeClockStyles);
const { activeAccountId } = useAccounts();
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 [stopNote, setStopNote] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [startMode, setStartMode] = useState<StartMode>("now");
const [agoMinutes, setAgoMinutes] = useState(60);
const [agoMinutesText, setAgoMinutesText] = useState("60");
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [clientsExpanded, setClientsExpanded] = useState(false);
const [editEntryId, setEditEntryId] = useState<string | null>(null);
const [runningStartedAt, setRunningStartedAt] = useState(() => new Date());
const [featuredClientIds, setFeaturedClientIds] = useState<string[]>([]);
const [storedLastClientId, setStoredLastClientId] = useState<string | null>(null);
const [prefsLoaded, setPrefsLoaded] = useState(false);
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 entriesQuery = api.timeEntries.getAll.useQuery();
const recentClientIds = useMemo(() => {
const seen = new Set<string>();
const ids: string[] = [];
for (const entry of entriesQuery.data ?? []) {
if (entry.clientId && !seen.has(entry.clientId)) {
seen.add(entry.clientId);
ids.push(entry.clientId);
if (ids.length >= 2) break;
}
}
return ids;
}, [entriesQuery.data]);
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(),
]);
},
});
const clockOut = api.timeEntries.clockOut.useMutation({
onSuccess: async (data) => {
await endTimeClockLiveActivity();
if (running?.clientId && activeAccountId) {
await setLastTimeClockClientId(activeAccountId, running.clientId);
}
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("");
setStopNote("");
},
});
useEffect(() => {
if (!activeAccountId) {
setPrefsLoaded(true);
return;
}
setPrefsLoaded(false);
void getLastTimeClockClientId(activeAccountId).then((id) => {
setStoredLastClientId(id);
setPrefsLoaded(true);
});
}, [activeAccountId]);
useEffect(() => {
if (!running) return;
setClientId(running.clientId ?? "");
setInvoiceId(running.invoiceId ?? "");
setDescription(running.description ?? "");
setStopNote("");
setRateText(running.rate != null ? String(running.rate) : "");
setRunningStartedAt(new Date(running.startedAt));
}, [running]);
useEffect(() => {
if (!clientId || running || clients.length === 0) return;
const client = clients.find((c) => c.id === clientId);
if (!client?.defaultHourlyRate) return;
setRateText((current) => current.trim() || clientRateText(client));
}, [clientId, clients, running]);
useEffect(() => {
if (featuredClientIds.length > 0 || !prefsLoaded || clients.length === 0) return;
const ids: string[] = [];
const add = (id: string | null | undefined) => {
if (!id || ids.includes(id)) return;
if (!clients.some((client) => client.id === id)) return;
ids.push(id);
};
add(storedLastClientId);
for (const id of recentClientIds) add(id);
setFeaturedClientIds(ids.slice(0, 1));
}, [clients, featuredClientIds.length, prefsLoaded, recentClientIds, storedLastClientId]);
const selectedClient = clients.find((client) => client.id === clientId);
const rateCurrency = selectedClient?.currency ?? "USD";
const effectiveRate = resolveEffectiveHourlyRate(
rateText,
selectedClient?.defaultHourlyRate,
);
const displayRate = running
? (running.rate ?? effectiveRate ?? 0)
: (effectiveRate ?? 0);
const featuredClients = useMemo(
() =>
featuredClientIds
.map((id) => clients.find((client) => client.id === id))
.filter((client) => client != null),
[clients, featuredClientIds],
);
const moreClients = useMemo(() => {
const featuredIds = new Set(featuredClientIds);
return clients
.filter((client) => !featuredIds.has(client.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [clients, featuredClientIds]);
const resolvedStartAt = useMemo(() => {
if (startMode === "now") return new Date();
if (startMode === "ago") return startedAtFromMinutesAgo(agoMinutes);
return startedAt;
}, [agoMinutes, startMode, startedAt]);
const clockInErrors = useMemo(() => {
const next: { rate?: string; start?: string } = {};
if (rateText.trim() && parseNonNegativeNumber(rateText) === null) {
next.rate = "Enter a valid hourly rate";
}
if (startMode === "ago" && agoMinutes <= 0) {
next.start = "Enter how long ago you started";
}
return next;
}, [agoMinutes, rateText, startMode]);
const canClockIn = Object.keys(clockInErrors).length === 0;
const optionsSummary = useMemo(() => {
const rate =
effectiveRate ??
(selectedClient?.defaultHourlyRate != null ? selectedClient.defaultHourlyRate : null);
const rateLabel = rate != null ? `${formatCurrency(rate, rateCurrency)}/hr` : "No rate";
const startLabel = startMode === "now" ? "Starting now" : formatDateTime(resolvedStartAt);
return `${rateLabel} · ${startLabel}`;
}, [effectiveRate, rateCurrency, resolvedStartAt, selectedClient?.defaultHourlyRate, startMode]);
const todayEntries = useMemo(
() =>
(entriesQuery.data ?? []).filter(
(entry) => entry.endedAt && new Date(entry.startedAt) >= todayStart,
),
[entriesQuery.data, todayStart],
);
async function persistClientChoice(nextClientId: string, syncState = false) {
if (!activeAccountId || !nextClientId) return;
await setLastTimeClockClientId(activeAccountId, nextClientId);
if (syncState) setStoredLastClientId(nextClientId);
}
function selectClient(nextClientId: string) {
const client = clients.find((c) => c.id === nextClientId);
if (running) {
updateRunning.mutate({
clientId: nextClientId,
invoiceId: "",
rate: client?.defaultHourlyRate ?? undefined,
});
return;
}
setClientId(nextClientId);
setInvoiceId("");
setRateText(clientRateText(client));
if (nextClientId && !featuredClientIds.includes(nextClientId)) {
setClientsExpanded(true);
}
if (nextClientId) {
void persistClientChoice(nextClientId);
}
}
function selectInvoice(nextInvoiceId: string) {
if (running) {
updateRunning.mutate({ invoiceId: nextInvoiceId || "" });
return;
}
setInvoiceId(nextInvoiceId);
}
function handleRunningDescriptionBlur() {
if (!running) return;
const next = resolveClockDescription(description);
if (next === (running.description ?? "")) return;
updateRunning.mutate({ description: next });
}
function handleRunningStartedAtChange(date: Date) {
setRunningStartedAt(date);
if (!running || date > new Date()) return;
updateRunning.mutate({ startedAt: date });
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode !== "now") setOptionsExpanded(true);
if (mode === "now") {
setStartedAt(new Date());
return;
}
if (mode === "ago") {
setStartedAt(startedAtFromMinutesAgo(agoMinutes));
return;
}
setStartedAt((current) =>
Math.abs(Date.now() - current.getTime()) < 60_000 ? current : new Date(),
);
}
function selectAgoPreset(minutes: number) {
setStartMode("ago");
setAgoMinutes(minutes);
setAgoMinutesText(String(minutes));
setStartedAt(startedAtFromMinutesAgo(minutes));
}
function handleAgoMinutesChange(text: string) {
setAgoMinutesText(text);
const parsed = Number(text);
if (!Number.isNaN(parsed) && parsed > 0) {
setAgoMinutes(parsed);
setStartedAt(startedAtFromMinutesAgo(parsed));
}
}
async function handleClockIn() {
if (!canClockIn) {
if (clockInErrors.rate || clockInErrors.start) setOptionsExpanded(true);
return;
}
try {
const backdated =
startMode === "now" ? undefined : resolvedStartAt;
await clockIn.mutateAsync({
description: resolveClockDescription(description),
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate ?? undefined,
startedAt: backdated,
});
if (clientId) {
await persistClientChoice(clientId);
}
setStartMode("now");
setStartedAt(new Date());
setAgoMinutes(60);
setAgoMinutesText("60");
} catch (err) {
Alert.alert("Clock in failed", err instanceof Error ? err.message : "Try again");
}
}
async function handleClockOut() {
try {
await clockOut.mutateAsync({
description: stopNote.trim() ? stopNote.trim() : undefined,
});
} catch (err) {
Alert.alert("Clock out failed", err instanceof Error ? err.message : "Try again");
}
}
if (runningQuery.isLoading || clientsQuery.isLoading) {
return <LoadingScreen message="Loading time clock…" />;
}
const runningTitle = formatRunningTimerLabel(running?.description);
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(" · ");
function renderClientChip(client: (typeof clients)[number]) {
return (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
);
}
return (
<TabScrollView
style={styles.scroll}
header={header}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
onRefresh={() => {
void runningQuery.refetch();
void clientsQuery.refetch();
void billableQuery.refetch();
void entriesQuery.refetch();
}}
tintColor={colors.primary}
/>
}
>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={[styles.hero, running && styles.heroRunning]}>
{running ? (
<>
<View style={styles.heroHeader}>
<View style={styles.pulseDot} />
<Text style={styles.heroLabelRunning}>Timer running</Text>
</View>
<Text style={styles.timerValue}>{formatElapsedSeconds(elapsed)}</Text>
<Text style={styles.runningTitle}>{runningTitle}</Text>
{runningMeta ? (
<Text style={styles.runningMeta}>{runningMeta}</Text>
) : null}
</>
) : (
<Text style={styles.idleHint}>
Start the timer anytime add client, invoice, and details later.
</Text>
)}
</View>
</GlassSurface>
) : null}
<GlassSurface style={styles.setupCard}>
<Text style={styles.cardTitle}>{running ? "Update & stop" : "Clock in"}</Text>
{running ? (
<View style={styles.formSection}>
<Input
label="What are you working on?"
value={description}
onChangeText={setDescription}
onBlur={handleRunningDescriptionBlur}
placeholder="What are you working on?"
returnKeyType="done"
/>
<DateTimeField
label="Started at"
value={runningStartedAt}
maximumDate={new Date()}
onChange={handleRunningStartedAtChange}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => selectClient("")}
/>
{clients.map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => selectClient(client.id)}
/>
))}
</View>
</View>
{clientId ? (
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Entry only"
active={!invoiceId}
onPress={() => selectInvoice("")}
/>
{billableInvoices.map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => selectInvoice(invoice.id)}
/>
))}
</View>
</View>
) : null}
<Input
label="Note on stop (optional)"
value={stopNote}
onChangeText={setStopNote}
placeholder={
running.description?.trim()
? running.description
: "Update description when you stop"
}
returnKeyType="done"
/>
<Button
title={clockOut.isPending ? "Stopping…" : "Stop & save"}
variant="danger"
loading={clockOut.isPending}
onPress={handleClockOut}
/>
</View>
) : (
<>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What are you working on?"
placeholderTextColor={colors.mutedForeground}
returnKeyType="done"
style={[styles.titleField, { color: colors.foreground }]}
/>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Client</Text>
{clients.length === 0 ? (
<Text style={styles.emptyClients}>
No clients yet you can still start the timer and assign a client later.
</Text>
) : (
<>
<View style={styles.chipWrap}>
<FilterChip
label="No client"
active={!clientId}
onPress={() => selectClient("")}
/>
{featuredClients.map((client) => renderClientChip(client))}
{moreClients.length > 0 ? (
<FilterChip
label={clientsExpanded ? "Show less" : "Show more"}
active={clientsExpanded}
onPress={() => setClientsExpanded((open) => !open)}
/>
) : null}
</View>
{clientsExpanded && moreClients.length > 0 ? (
<View style={[styles.chipWrap, styles.moreClientsWrap]}>
{moreClients.map((client) => renderClientChip(client))}
</View>
) : null}
</>
)}
</View>
<View style={styles.setupSection}>
<Text style={styles.sectionLabel}>Invoice (optional)</Text>
{!clientId ? (
<Text style={styles.emptyClients}>
No invoice for now. Add a client and invoice later if this becomes billable.
</Text>
) : (
<View style={styles.chipWrap}>
<FilterChip label="Entry only" active={!invoiceId} onPress={() => setInvoiceId("")} />
{billableInvoices.map((invoice) => {
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
return (
<FilterChip
key={invoice.id}
label={label}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
);
})}
</View>
)}
</View>
<View style={styles.setupSection}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: optionsExpanded }}
onPress={() => setOptionsExpanded((open) => !open)}
style={({ pressed }) => [styles.optionsToggle, pressed && styles.optionsTogglePressed]}
>
<View style={styles.optionsToggleText}>
<Text style={styles.optionsToggleLabel}>Rate & start time</Text>
{!optionsExpanded ? (
<Text style={styles.optionsToggleSummary}>{optionsSummary}</Text>
) : null}
</View>
<Text style={styles.optionsChevron}>{optionsExpanded ? "" : "+"}</Text>
</Pressable>
{optionsExpanded ? (
<View style={styles.optionsBody}>
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
placeholder={
selectedClient?.defaultHourlyRate != null
? String(selectedClient.defaultHourlyRate)
: "0"
}
error={clockInErrors.rate}
/>
{selectedClient?.defaultHourlyRate != null && !rateText.trim() ? (
<Text style={styles.rateHint}>
Defaults to{" "}
{formatCurrency(selectedClient.defaultHourlyRate, rateCurrency)}/hr from client
</Text>
) : null}
<Text style={[styles.sectionLabel, styles.sectionLabelInset]}>Start</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Now"
active={startMode === "now"}
onPress={() => selectStartMode("now")}
/>
<FilterChip
label="Pick time"
active={startMode === "at"}
onPress={() => selectStartMode("at")}
/>
<FilterChip
label="Time ago"
active={startMode === "ago"}
onPress={() => selectStartMode("ago")}
/>
</View>
{startMode === "at" ? (
<DateTimeField
label="Started at"
value={startedAt}
maximumDate={new Date()}
onChange={(date) => {
setStartedAt(date);
setStartMode("at");
}}
/>
) : null}
{startMode === "ago" ? (
<View style={styles.agoBlock}>
<View style={styles.chipWrap}>
{AGO_PRESETS.map((preset) => (
<FilterChip
key={preset.label}
label={preset.label}
active={agoMinutes === preset.minutes}
onPress={() => selectAgoPreset(preset.minutes)}
/>
))}
</View>
<View style={styles.agoCustomRow}>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
Started
</Text>
<TextInput
value={agoMinutesText}
onChangeText={handleAgoMinutesChange}
keyboardType="number-pad"
style={[
styles.agoInput,
{ color: colors.foreground, borderColor: colors.border },
]}
/>
<Text style={[styles.agoCustomLabel, { color: colors.mutedForeground }]}>
min ago
</Text>
</View>
</View>
) : null}
{clockInErrors.start ? (
<Text style={styles.fieldError}>{clockInErrors.start}</Text>
) : null}
</View>
) : null}
</View>
<Button
title={clockIn.isPending ? "Starting…" : "Start timer"}
loading={clockIn.isPending}
disabled={!canClockIn}
showArrow={!clockIn.isPending}
onPress={handleClockIn}
/>
</>
)}
</GlassSurface>
{todayEntries.length > 0 ? (
<Card title="Today's entries">
{todayEntries.map((entry) => {
const invoiceLabel = entry.invoice
? `${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: null;
return (
<SwipeableRow
key={entry.id}
actions={[
{
key: "edit",
label: "Edit",
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => setEditEntryId(entry.id),
},
{
key: "invoice",
label: "Invoice",
icon: "document-text-outline",
color: "#fff",
backgroundColor: colors.mutedForeground,
onPress: () => {
if (entry.invoice?.id) {
router.push(`/(app)/invoices/${entry.invoice.id}`);
} else {
setEditEntryId(entry.id);
}
},
},
]}
>
<View style={styles.entryRow}>
<View style={styles.entryMeta}>
<Text style={styles.entryTitle}>{formatRunningTimerLabel(entry.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>
</View>
</SwipeableRow>
);
})}
</Card>
) : null}
<TimeEntryEditSheet
entryId={editEntryId}
visible={editEntryId != null}
onClose={() => setEditEntryId(null)}
/>
</TabScrollView>
);
}
const createTimeClockStyles = (colors: ThemeColors, isDark: boolean) =>
StyleSheet.create({
scroll: {
flex: 1,
},
runningCard: {
borderColor: isDark ? "rgba(250, 250, 250, 0.12)" : "rgba(24, 24, 27, 0.12)",
backgroundColor: isDark ? "rgba(250, 250, 250, 0.06)" : "rgba(24, 24, 27, 0.04)",
},
hero: {
padding: spacing.lg,
gap: spacing.sm,
},
heroRunning: {
alignItems: "center",
},
heroHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
pulseDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
heroLabelRunning: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
color: colors.primary,
},
timerValue: {
fontSize: 56,
lineHeight: 60,
fontFamily: fonts.mono,
color: colors.primary,
fontVariant: ["tabular-nums"],
textAlign: "center",
},
runningTitle: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
color: colors.foreground,
textAlign: "center",
},
runningMeta: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
textAlign: "center",
},
idleHint: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
lineHeight: 20,
},
setupCard: {
padding: spacing.lg,
gap: spacing.lg,
},
cardTitle: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
formSection: {
gap: spacing.md,
},
titleField: {
fontSize: 18,
fontFamily: fonts.bodyMedium,
minHeight: 48,
paddingVertical: spacing.xs,
},
setupSection: {
gap: spacing.sm,
paddingTop: spacing.lg,
},
sectionLabel: {
fontSize: 11,
fontFamily: fonts.bodySemiBold,
color: colors.mutedForeground,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabelInset: {
marginTop: spacing.sm,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
moreClientsWrap: {
paddingTop: spacing.xs,
},
emptyClients: {
fontSize: 14,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
fieldError: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.destructive,
},
optionsToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
optionsTogglePressed: {
opacity: 0.7,
},
optionsToggleText: {
flex: 1,
gap: 2,
},
optionsToggleLabel: {
fontSize: 14,
fontFamily: fonts.bodySemiBold,
color: colors.foreground,
},
optionsToggleSummary: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
optionsChevron: {
fontSize: 20,
lineHeight: 22,
fontFamily: fonts.bodyMedium,
color: colors.mutedForeground,
},
optionsBody: {
gap: spacing.md,
paddingBottom: spacing.xs,
},
rateHint: {
fontSize: 12,
fontFamily: fonts.body,
color: colors.mutedForeground,
},
agoBlock: {
gap: spacing.sm,
},
agoCustomRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
agoCustomLabel: {
fontSize: 14,
fontFamily: fonts.body,
},
agoInput: {
minWidth: 56,
fontSize: 16,
fontFamily: fonts.bodySemiBold,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 4,
textAlign: "center",
},
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,267 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { FilterChip } from "@/components/FilterChip";
import { Button } from "@/components/ui/Button";
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 } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type TimeEntryEditSheetProps = {
entryId: string | null;
visible: boolean;
onClose: () => void;
};
export function TimeEntryEditSheet({ entryId, visible, onClose }: TimeEntryEditSheetProps) {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
const utils = api.useUtils();
const entryQuery = api.timeEntries.getById.useQuery(
{ id: entryId ?? "" },
{ enabled: visible && Boolean(entryId) },
);
const clientsQuery = api.clients.getAll.useQuery(undefined, { enabled: visible });
const [description, setDescription] = useState("");
const [clientId, setClientId] = useState("");
const [invoiceId, setInvoiceId] = useState("");
const [rateText, setRateText] = useState("");
const [startedAt, setStartedAt] = useState(() => new Date());
const [endedAt, setEndedAt] = useState(() => new Date());
const billableQuery = api.invoices.getBillable.useQuery(
clientId ? { clientId } : undefined,
{ enabled: visible && Boolean(clientId) },
);
useEffect(() => {
const entry = entryQuery.data;
if (!entry) return;
setDescription(entry.description ?? "");
setClientId(entry.clientId ?? "");
setInvoiceId(entry.invoiceId ?? "");
setRateText(entry.rate != null ? String(entry.rate) : "");
setStartedAt(new Date(entry.startedAt));
setEndedAt(entry.endedAt ? new Date(entry.endedAt) : new Date());
}, [entryQuery.data]);
const hoursPreview = useMemo(() => {
if (endedAt <= startedAt) return null;
return Math.max(0, (endedAt.getTime() - startedAt.getTime()) / 3_600_000);
}, [endedAt, startedAt]);
const rate = parseNonNegativeNumber(rateText);
const updateEntry = api.timeEntries.update.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.timeEntries.getById.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not save", err.message),
});
const deleteEntry = api.timeEntries.delete.useMutation({
onSuccess: async () => {
await Promise.all([
utils.timeEntries.getAll.invalidate(),
utils.invoices.getAll.invalidate(),
utils.dashboard.getStats.invalidate(),
]);
onClose();
},
onError: (err) => Alert.alert("Could not delete", err.message),
});
function handleSave() {
if (!entryId) return;
if (endedAt <= startedAt) {
Alert.alert("Invalid times", "End time must be after start time.");
return;
}
updateEntry.mutate({
id: entryId,
description,
clientId: clientId || "",
invoiceId: invoiceId || "",
rate: rate ?? undefined,
startedAt,
endedAt,
hours: hoursPreview ?? undefined,
});
}
function confirmDelete() {
if (!entryId) return;
Alert.alert("Delete time entry?", "This removes the entry and any linked invoice line.", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteEntry.mutate({ id: entryId }),
},
]);
}
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.foreground }]}>Edit time entry</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Text style={[styles.close, { color: colors.mutedForeground }]}>Close</Text>
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{entryQuery.isLoading ? (
<Text style={{ color: colors.mutedForeground }}>Loading</Text>
) : (
<>
<Input label="Description" value={description} onChangeText={setDescription} />
<Text style={[styles.label, { color: colors.foreground }]}>Client</Text>
<View style={styles.chipWrap}>
<FilterChip
label="None"
active={!clientId}
onPress={() => {
setClientId("");
setInvoiceId("");
}}
/>
{(clientsQuery.data ?? []).map((client) => (
<FilterChip
key={client.id}
label={client.name}
active={clientId === client.id}
onPress={() => {
setClientId(client.id);
setInvoiceId("");
}}
/>
))}
</View>
{clientId ? (
<>
<Text style={[styles.label, { color: colors.foreground }]}>Invoice</Text>
<View style={styles.chipWrap}>
<FilterChip
label="Not on invoice"
active={!invoiceId}
onPress={() => setInvoiceId("")}
/>
{(billableQuery.data ?? []).map((invoice) => (
<FilterChip
key={invoice.id}
label={`${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`}
active={invoiceId === invoice.id}
onPress={() => setInvoiceId(invoice.id)}
/>
))}
</View>
</>
) : null}
<Input
label="Hourly rate"
value={rateText}
onChangeText={setRateText}
keyboardType="decimal-pad"
/>
<DateTimeField
label="Started"
value={startedAt}
maximumDate={endedAt}
onChange={setStartedAt}
/>
<DateTimeField label="Ended" value={endedAt} minimumDate={startedAt} onChange={setEndedAt} />
{hoursPreview != null ? (
<Text style={[styles.preview, { color: colors.mutedForeground }]}>
{hoursPreview.toFixed(2)}h
{rate != null && rate > 0
? ` · ${formatCurrency(hoursPreview * rate)}`
: ""}
</Text>
) : null}
<Button title="Save changes" loading={updateEntry.isPending} onPress={handleSave} />
<Button
title="Delete entry"
variant="danger"
loading={deleteEntry.isPending}
onPress={confirmDelete}
/>
</>
)}
</ScrollView>
</View>
</Modal>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
},
close: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
body: {
padding: spacing.lg,
gap: spacing.md,
},
label: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
chipWrap: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
preview: {
fontFamily: fonts.body,
fontSize: 14,
},
});
+135
View File
@@ -0,0 +1,135 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type PressableProps,
type ViewStyle,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
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;
leftIcon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
};
export function Button({
title,
loading,
variant = "primary",
disabled,
style,
leftIcon,
showArrow = false,
...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: colors.cardGlass,
borderWidth: 1,
borderColor: colors.borderGlass,
},
} 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}
/>
) : (
<View style={styles.content}>
{leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={labelStyles[variant].color}
/>
) : null}
<Text style={[styles.label, labelStyles[variant]]} numberOfLines={1}>
{title}
</Text>
{showArrow ? (
<Ionicons
name="arrow-forward"
size={16}
color={labelStyles[variant].color}
style={styles.arrow}
/>
) : null}
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 44,
borderRadius: radii.lg,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.md,
},
content: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
minWidth: 0,
},
arrow: {
marginTop: 1,
},
pressed: {
opacity: 0.92,
},
disabled: {
opacity: 0.55,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
flexShrink: 1,
},
});
+38
View File
@@ -0,0 +1,38 @@
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,
alignItems: "stretch",
},
title: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
});
@@ -0,0 +1,174 @@
import DateTimePicker, {
type DateTimePickerEvent,
} from "@react-native-community/datetimepicker";
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatShortDate } from "@/lib/format";
type CompactDateFieldProps = {
value: Date;
onChange: (date: Date) => void;
style?: StyleProp<ViewStyle>;
maximumDate?: Date;
minimumDate?: Date;
};
export function CompactDateField({
value,
onChange,
style,
maximumDate = new Date(2100, 0, 1),
minimumDate,
}: CompactDateFieldProps) {
const { colors, isDark } = useAppTheme();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
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 (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Change date"
onPress={() => {
setDraft(value);
setOpen(true);
}}
style={({ pressed }) => [
styles.trigger,
{
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
pressed && styles.pressed,
style,
]}
>
<Text style={[styles.value, { color: colors.foreground }]} numberOfLines={1}>
{formatShortDate(value)}
</Text>
<Ionicons name="chevron-down" size={12} 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.action, { color: colors.mutedForeground }]}>Cancel</Text>
</Pressable>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Date</Text>
<Pressable
onPress={() => {
applyDate(draft);
setOpen(false);
}}
>
<Text style={[styles.action, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={draft}
mode="date"
display="spinner"
maximumDate={maximumDate}
minimumDate={minimumDate}
themeVariant={isDark ? "dark" : "light"}
onChange={handleChange}
/>
</Pressable>
</Pressable>
</Modal>
) : open ? (
<DateTimePicker
value={draft}
mode="date"
maximumDate={maximumDate}
minimumDate={minimumDate}
onChange={handleChange}
/>
) : null}
</>
);
}
const styles = StyleSheet.create({
trigger: {
minHeight: 36,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 8,
gap: 2,
},
pressed: {
opacity: 0.9,
},
value: {
flex: 1,
fontFamily: fonts.body,
fontSize: 12,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.lg,
borderTopRightRadius: radii.lg,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
action: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
});
@@ -0,0 +1,92 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, TextInput, View, type StyleProp, type ViewStyle } from "react-native";
import { fonts, radii } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type CompactStepperInputProps = {
value: string;
onChangeText: (value: string) => void;
step?: number;
min?: number;
style?: StyleProp<ViewStyle>;
};
export function CompactStepperInput({
value,
onChangeText,
step = 0.25,
min = 0,
style,
}: CompactStepperInputProps) {
const { colors } = useAppTheme();
function adjust(delta: number) {
const current = Number.parseFloat(value) || 0;
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
onChangeText(Number.isInteger(next) ? String(next) : String(next));
}
return (
<View
style={[
styles.field,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
style,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Decrease hours"
hitSlop={4}
onPress={() => adjust(-step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
>
<Ionicons name="remove" size={14} color={colors.foreground} />
</Pressable>
<TextInput
value={value}
onChangeText={onChangeText}
keyboardType="decimal-pad"
placeholder="0"
placeholderTextColor={colors.mutedForeground}
style={[styles.input, { color: colors.foreground }]}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel="Increase hours"
hitSlop={4}
onPress={() => adjust(step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.pressed]}
>
<Ionicons name="add" size={14} color={colors.foreground} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
field: {
minHeight: 36,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
},
stepButton: {
width: 28,
height: 36,
alignItems: "center",
justifyContent: "center",
},
pressed: {
opacity: 0.65,
},
input: {
flex: 1,
textAlign: "center",
fontSize: 13,
fontFamily: fonts.bodyMedium,
paddingVertical: 4,
},
});
+184
View File
@@ -0,0 +1,184 @@
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,
alignSelf: "stretch",
width: "100%",
},
label: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
alignSelf: "stretch",
width: "100%",
borderWidth: 1,
borderRadius: radii.lg,
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,
},
});
+118
View File
@@ -0,0 +1,118 @@
import {
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fonts, radii, spacing } from "@/constants/theme";
type InputProps = TextInputProps & {
label: string;
error?: string;
required?: boolean;
leftIcon?: keyof typeof Ionicons.glyphMap;
labelAccessory?: React.ReactNode;
hint?: string;
};
export function Input({
label,
error,
required,
leftIcon,
labelAccessory,
hint,
style,
...props
}: InputProps) {
const { colors } = useAppTheme();
return (
<View style={styles.wrapper}>
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.foreground }]}>
{label}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
{labelAccessory}
</View>
<View style={styles.field}>
{leftIcon ? (
<Ionicons
name={leftIcon}
size={16}
color={colors.mutedForeground}
style={styles.leftIcon}
/>
) : null}
<TextInput
placeholderTextColor={colors.mutedForeground}
style={[
styles.input,
leftIcon && styles.inputWithIcon,
{
borderColor: colors.border,
color: colors.foreground,
backgroundColor: colors.cardGlass,
},
error && { borderColor: colors.destructive },
style,
]}
{...props}
/>
</View>
{hint && !error ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>{hint}</Text>
) : null}
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
labelRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
field: {
position: "relative",
justifyContent: "center",
},
leftIcon: {
position: "absolute",
left: spacing.md,
zIndex: 1,
},
input: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: fonts.body,
},
inputWithIcon: {
paddingLeft: spacing.md + 24,
},
hint: {
fontSize: 12,
fontFamily: fonts.body,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
});
+218
View File
@@ -0,0 +1,218 @@
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;
required?: boolean;
error?: string;
onValueChange: (value: string) => void;
};
export function SelectField({
label,
placeholder,
value,
options,
disabled,
required,
error,
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}
{required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={() => setOpen(true)}
style={({ pressed }) => [
styles.trigger,
{
borderColor: error ? colors.destructive : 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>
{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,
},
error: {
fontSize: 13,
fontFamily: fonts.body,
},
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,
},
});
+106
View File
@@ -0,0 +1,106 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
type StepperInputProps = Omit<TextInputProps, "value" | "onChangeText"> & {
label: string;
value: string;
onChangeText: (value: string) => void;
step?: number;
min?: number;
};
export function StepperInput({
label,
value,
onChangeText,
step = 0.25,
min = 0,
...props
}: StepperInputProps) {
const { colors } = useAppTheme();
function adjust(delta: number) {
const current = Number.parseFloat(value) || 0;
const next = Math.max(min, Math.round((current + delta) * 100) / 100);
onChangeText(Number.isInteger(next) ? String(next) : String(next));
}
return (
<View style={styles.wrapper}>
<Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
<View
style={[
styles.field,
{ borderColor: colors.border, backgroundColor: colors.cardGlass },
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Decrease ${label}`}
hitSlop={6}
onPress={() => adjust(-step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
>
<Ionicons name="remove" size={18} color={colors.foreground} />
</Pressable>
<TextInput
value={value}
onChangeText={onChangeText}
keyboardType="decimal-pad"
placeholderTextColor={colors.mutedForeground}
style={[styles.input, { color: colors.foreground }]}
{...props}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increase ${label}`}
hitSlop={6}
onPress={() => adjust(step)}
style={({ pressed }) => [styles.stepButton, pressed && styles.stepPressed]}
>
<Ionicons name="add" size={18} color={colors.foreground} />
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: spacing.sm,
},
label: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
field: {
minHeight: 44,
borderWidth: 1,
borderRadius: radii.md,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.xs,
},
stepButton: {
width: 36,
height: 36,
alignItems: "center",
justifyContent: "center",
borderRadius: radii.sm,
},
stepPressed: {
opacity: 0.65,
},
input: {
flex: 1,
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
paddingVertical: spacing.sm,
},
});
@@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}
@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = useState<S | C>(server);
useEffect(() => {
setValue(client);
}, [client]);
return value;
}
+6
View File
@@ -0,0 +1,6 @@
import { useColorScheme as useColorSchemeCore } from 'react-native';
export const useColorScheme = () => {
const coreScheme = useColorSchemeCore();
return coreScheme === 'unspecified' ? 'light' : coreScheme;
};
@@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}