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,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,
},
});