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

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
@@ -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,
},
});