Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
|
||||
import { BrandBackground } from "@/components/BrandBackground";
|
||||
|
||||
/** Auth screens — brand grid/blob behind content. */
|
||||
export function AuthBackground({ style, children, ...props }: ViewProps) {
|
||||
return (
|
||||
<View style={[styles.root, style]} {...props}>
|
||||
<BrandBackground />
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** App tab/stack screens — brand grid/blob behind content (native tabs block the root layer). */
|
||||
export function AppBackground({ style, children, ...props }: ViewProps) {
|
||||
return (
|
||||
<View style={[styles.root, style]} {...props}>
|
||||
<BrandBackground />
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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%",
|
||||
},
|
||||
});
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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'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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { BrandBackground } from "@/components/BrandBackground";
|
||||
import { LogoMark } from "@/components/Logo";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { fonts } from "@/constants/theme";
|
||||
|
||||
export function LoadingScreen({ message = "Loading…" }: { message?: string }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { colors } = useAppTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<BrandBackground />
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||
]}
|
||||
>
|
||||
<LogoMark />
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
message: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 4–6 digits");
|
||||
return;
|
||||
}
|
||||
if (requireConfirmation && pin !== confirmPin) {
|
||||
setError("PINs do not match");
|
||||
return;
|
||||
}
|
||||
onSubmit(pin);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<Pressable style={styles.backdrop} onPress={onCancel}>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
onPress={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
|
||||
<Text style={[styles.message, { color: colors.mutedForeground }]}>{message}</Text>
|
||||
|
||||
<TextInput
|
||||
value={pin}
|
||||
onChangeText={(value) => {
|
||||
setError("");
|
||||
setPin(value.replace(/\D/g, "").slice(0, 6));
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
secureTextEntry
|
||||
maxLength={6}
|
||||
placeholder="PIN"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||
]}
|
||||
/>
|
||||
|
||||
{requireConfirmation ? (
|
||||
<TextInput
|
||||
value={confirmPin}
|
||||
onChangeText={(value) => {
|
||||
setError("");
|
||||
setConfirmPin(value.replace(/\D/g, "").slice(0, 6));
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
secureTextEntry
|
||||
maxLength={6}
|
||||
placeholder="Confirm PIN"
|
||||
placeholderTextColor={colors.mutedForeground}
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: colors.foreground, borderColor: colors.border, backgroundColor: colors.background },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? <Text style={[styles.error, { color: colors.destructive }]}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button title="Cancel" variant="secondary" onPress={onCancel} />
|
||||
<Button title={confirmLabel} onPress={handleSubmit} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: spacing.lg,
|
||||
backgroundColor: "rgba(0,0,0,0.35)",
|
||||
},
|
||||
sheet: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 20,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
minHeight: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 20,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
textAlign: "center",
|
||||
},
|
||||
error: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.bodyMedium,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, type ViewStyle } from "react-native";
|
||||
import { SafeAreaView, type Edge } from "react-native-safe-area-context";
|
||||
|
||||
type ScreenProps = {
|
||||
children: ReactNode;
|
||||
style?: ViewStyle;
|
||||
/**
|
||||
* Safe area edges to pad. Default: top + sides (Dynamic Island / notch).
|
||||
* Tab screens usually omit bottom — the tab bar handles home-indicator spacing.
|
||||
*/
|
||||
edges?: Edge[];
|
||||
};
|
||||
|
||||
/** Full-screen wrapper that respects Dynamic Island, notch, and side insets. */
|
||||
export function Screen({ children, style, edges = ["top", "left", "right"] }: ScreenProps) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.screen, style]} edges={edges}>
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/** Auth / modal screens that aren't inside a tab bar. */
|
||||
export function FullScreen({ children, style }: Omit<ScreenProps, "edges">) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.screen, style]} edges={["top", "bottom", "left", "right"]}>
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { getStatusColor, statusLabels, type InvoiceStatus } from "@/lib/invoice-status";
|
||||
|
||||
export function StatusBadge({ status }: { status: InvoiceStatus }) {
|
||||
const { isDark } = useAppTheme();
|
||||
const color = getStatusColor(status, isDark);
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: `${color}22` }]}>
|
||||
<Text style={[styles.text, { color }]}>{statusLabels[status]}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
height: 22,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.pill,
|
||||
},
|
||||
text: {
|
||||
fontSize: 10,
|
||||
fontFamily: fonts.bodyBold,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
includeFontPadding: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Text, TextProps } from './Themed';
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user