Files
native/src/screens/ContactFormScreen.tsx
T
Linus Rath d5a336519a feat: add rich text editor component and enhance email thread screen with attachment handling
- Implemented a new RichTextEditor component for composing and editing rich text emails.
- Enhanced EmailThreadScreen to support downloading attachments with loading indicators.
- Added functionality to expand/collapse attachment list in EmailThreadScreen.
- Created utility functions for building initial HTML for email composition and converting HTML to plain text.
- Added tests for HTML composition and conversion utilities.
2026-04-27 17:14:22 +02:00

1642 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import {
View, Text, StyleSheet, Pressable, ScrollView, TextInput, Alert,
KeyboardAvoidingView, Platform, Modal, Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
import {
ArrowLeft, Mail, Phone, Building, MapPin, Cake, Tag, FileText, User, Check, Calendar,
Camera, X as XIcon, Globe, Heart, UserCircle, Plus, ChevronDown, ChevronRight, Book,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type {
ContactCard, ContactEmail, ContactPhone, ContactAddress, ContactOrganization,
ContactAnniversary, ContactNote, ContactMedia, PartialDate, ContactOnlineService,
ContactPersonalInfo, ContactNickname,
} from '../api/types';
import { useContactsStore } from '../stores/contacts-store';
import { getContactKeywords, getContactDisplayName } from '../lib/contact-utils';
import Dialog from '../components/Dialog';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
interface EmailDraft { address: string; context: string }
interface PhoneDraft { number: string; context: string; feature: string }
interface AddressDraft {
street: string;
locality: string;
region: string;
postcode: string;
country: string;
context: string;
}
interface OrgDraft { name: string; department: string; jobTitle: string; role: string }
interface AnniversaryDraft { kind: string; date: string }
interface OnlineDraft { uri: string; service: string }
interface PersonalInfoDraft { kind: string; level: string; value: string }
interface NoteDraft { note: string }
interface FormState {
prefix: string;
given: string;
middle: string;
surname: string;
suffix: string;
full: string;
nickname: string;
emails: EmailDraft[];
phones: PhoneDraft[];
addresses: AddressDraft[];
orgs: OrgDraft[];
anniversaries: AnniversaryDraft[];
online: OnlineDraft[];
personalInfo: PersonalInfoDraft[];
notes: NoteDraft[];
keywords: string[];
grammaticalGender: string;
pronouns: string;
calendarUri: string;
schedulingUri: string;
freeBusyUri: string;
addressBookId: string;
photoUri: string;
photoMediaType: string;
}
function blankForm(): FormState {
return {
prefix: '',
given: '',
middle: '',
surname: '',
suffix: '',
full: '',
nickname: '',
emails: [{ address: '', context: '' }],
phones: [],
addresses: [],
orgs: [],
anniversaries: [],
online: [],
personalInfo: [],
notes: [],
keywords: [],
grammaticalGender: '',
pronouns: '',
calendarUri: '',
schedulingUri: '',
freeBusyUri: '',
addressBookId: '',
photoUri: '',
photoMediaType: '',
};
}
function partialDateToString(d: ContactAnniversary['date']): string {
if (typeof d === 'string') return d;
if (typeof d === 'object' && d) {
if ('@type' in d && d['@type'] === 'Timestamp') return d.utc.split('T')[0];
const pd = d as PartialDate;
const yr = pd.year ? String(pd.year).padStart(4, '0') : '';
const mo = pd.month ? String(pd.month).padStart(2, '0') : '';
const da = pd.day ? String(pd.day).padStart(2, '0') : '';
if (yr) return `${yr}-${mo || '01'}-${da || '01'}`;
if (mo || da) return `--${mo}-${da}`;
}
return '';
}
function stringToAnniversaryDate(s: string): string | PartialDate | null {
const trimmed = s.trim();
if (!trimmed) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const [y, m, d] = trimmed.split('-').map((n) => parseInt(n, 10));
return { year: y, month: m, day: d };
}
if (/^--\d{2}-\d{2}$/.test(trimmed)) {
const [, , mo, da] = trimmed.split('-');
return { month: parseInt(mo, 10), day: parseInt(da, 10) };
}
return trimmed;
}
function findNameComponent(contact: ContactCard | undefined, ...kinds: string[]): string {
if (!contact?.name?.components) return '';
const found = contact.name.components.find((c) => kinds.includes(c.kind as string));
return found?.value || '';
}
function addressToFlat(a: ContactAddress): AddressDraft {
if (a.components && a.components.length > 0) {
const collect = (kind: string) => a.components!.filter((c) => c.kind === kind).map((c) => c.value).join(' ');
const number = collect('number');
const name = collect('name');
return {
street: [number, name].filter(Boolean).join(' ') || a.street || '',
locality: collect('locality') || a.locality || '',
region: collect('region') || a.region || '',
postcode: collect('postcode') || a.postcode || '',
country: collect('country') || a.country || '',
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
return {
street: a.street || '',
locality: a.locality || '',
region: a.region || '',
postcode: a.postcode || '',
country: a.country || '',
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
function contactToForm(contact: ContactCard): FormState {
const prefix = findNameComponent(contact, 'title', 'prefix');
const given = findNameComponent(contact, 'given');
const middle = findNameComponent(contact, 'given2', 'additional', 'middle');
const surname = findNameComponent(contact, 'surname');
const suffix = findNameComponent(contact, 'generation', 'suffix');
const full = contact.name?.full || '';
const nickname = contact.nicknames
? Object.values(contact.nicknames)[0]?.name || ''
: '';
const emails = contact.emails ? Object.values(contact.emails).map((e) => ({
address: e.address,
context: e.contexts?.work ? 'work' : e.contexts?.private ? 'private' : '',
})) : [];
const phones = contact.phones ? Object.values(contact.phones).map((p) => ({
number: p.number,
context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '',
feature:
p.features?.cell ? 'cell'
: p.features?.fax ? 'fax'
: p.features?.pager ? 'pager'
: p.features?.video ? 'video'
: p.features?.text ? 'text'
: p.features?.voice ? 'voice'
: '',
})) : [];
const addresses = contact.addresses ? Object.values(contact.addresses).map(addressToFlat) : [];
// Pair organization and titles into a single "work" record per index.
const rawOrgs = contact.organizations ? Object.values(contact.organizations) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const orgs: OrgDraft[] = [];
const maxLen = Math.max(rawOrgs.length, titles.length);
for (let i = 0; i < maxLen; i++) {
const o = rawOrgs[i];
const t = titles[i];
orgs.push({
name: o?.name || '',
department: o?.units?.[0]?.name || '',
jobTitle: t?.kind !== 'role' ? t?.name || '' : '',
role: t?.kind === 'role' ? t.name : '',
});
}
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries).map((a) => ({
kind: a.kind || 'birth',
date: partialDateToString(a.date),
})) : [];
const online = contact.onlineServices ? Object.values(contact.onlineServices).map((s) => ({
uri: s.uri || '',
service: s.service || '',
})) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo).map((pi) => ({
kind: pi.kind || 'hobby',
level: pi.level || '',
value: pi.value || '',
})) : [];
const notes = contact.notes ? Object.values(contact.notes).map((n) => ({ note: n.note })) : [];
const keywords = contact.keywords
? Object.keys(contact.keywords).filter((k) => contact.keywords![k])
: [];
const addressBookId = Object.keys(contact.addressBookIds || {})
.find((id) => contact.addressBookIds[id]) || '';
const photoEntry = contact.media
? Object.values(contact.media).find((m) => m.kind === 'photo')
: undefined;
const photoUri = photoEntry?.uri || '';
const photoMediaType = photoEntry?.mediaType || '';
const grammaticalGender = contact.speakToAs?.grammaticalGender || '';
const pronouns = contact.speakToAs?.pronouns
? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || ''
: '';
return {
prefix, given, middle, surname, suffix, full, nickname,
emails: emails.length > 0 ? emails : [{ address: '', context: '' }],
phones, addresses, orgs, anniversaries, online,
personalInfo, notes, keywords, addressBookId, photoUri, photoMediaType,
grammaticalGender, pronouns,
calendarUri: contact.calendarUri || '',
schedulingUri: contact.schedulingUri || '',
freeBusyUri: contact.freeBusyUri || '',
};
}
function formToPatch(form: FormState): Partial<ContactCard> {
const components: Array<{ kind: string; value: string }> = [];
if (form.prefix) components.push({ kind: 'title', value: form.prefix });
if (form.given) components.push({ kind: 'given', value: form.given });
if (form.middle) components.push({ kind: 'given2', value: form.middle });
if (form.surname) components.push({ kind: 'surname', value: form.surname });
if (form.suffix) components.push({ kind: 'generation', value: form.suffix });
const name: ContactCard['name'] | undefined =
components.length > 0 || form.full
? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(form.full ? { full: form.full } : {}) }
: undefined;
const nicknames: Record<string, ContactNickname> | undefined =
form.nickname.trim() ? { n0: { name: form.nickname.trim() } } : undefined;
const emails: Record<string, ContactEmail> = {};
form.emails.filter((e) => e.address.trim()).forEach((e, i) => {
emails[`e${i + 1}`] = {
address: e.address.trim(),
...(e.context ? { contexts: { [e.context]: true } } : {}),
};
});
const phones: Record<string, ContactPhone> = {};
form.phones.filter((p) => p.number.trim()).forEach((p, i) => {
phones[`p${i + 1}`] = {
number: p.number.trim(),
...(p.context ? { contexts: { [p.context]: true } } : {}),
...(p.feature ? { features: { [p.feature]: true } } : {}),
};
});
const addresses: Record<string, ContactAddress> = {};
form.addresses
.filter((a) => a.street.trim() || a.locality.trim() || a.country.trim() || a.region.trim() || a.postcode.trim())
.forEach((a, i) => {
const comps: Array<{ kind: string; value: string }> = [];
if (a.street.trim()) comps.push({ kind: 'name', value: a.street.trim() });
if (a.locality.trim()) comps.push({ kind: 'locality', value: a.locality.trim() });
if (a.region.trim()) comps.push({ kind: 'region', value: a.region.trim() });
if (a.postcode.trim()) comps.push({ kind: 'postcode', value: a.postcode.trim() });
if (a.country.trim()) comps.push({ kind: 'country', value: a.country.trim() });
addresses[`a${i + 1}`] = {
components: comps,
isOrdered: true,
defaultSeparator: ', ',
...(a.context ? { contexts: { [a.context]: true } } : {}),
};
});
const organizations: Record<string, ContactOrganization> = {};
const titles: Record<string, { name: string; kind?: 'title' | 'role' }> = {};
form.orgs.forEach((o, i) => {
if (o.name.trim() || o.department.trim()) {
const units = o.department.trim() ? [{ name: o.department.trim() }] : undefined;
organizations[`o${i + 1}`] = {
...(o.name.trim() ? { name: o.name.trim() } : {}),
...(units ? { units } : {}),
};
}
if (o.jobTitle.trim()) {
titles[`t${i + 1}`] = { name: o.jobTitle.trim(), kind: 'title' };
}
if (o.role.trim()) {
titles[`r${i + 1}`] = { name: o.role.trim(), kind: 'role' };
}
});
const anniversaries: Record<string, ContactAnniversary> = {};
form.anniversaries.forEach((a, i) => {
const date = stringToAnniversaryDate(a.date);
if (!date) return;
anniversaries[`an${i + 1}`] = { kind: a.kind as ContactAnniversary['kind'], date };
});
const onlineServices: Record<string, ContactOnlineService> = {};
form.online.filter((s) => s.uri.trim()).forEach((s, i) => {
onlineServices[`os${i + 1}`] = {
uri: s.uri.trim(),
...(s.service.trim() ? { service: s.service.trim() } : {}),
};
});
const personalInfo: Record<string, ContactPersonalInfo> = {};
form.personalInfo.filter((p) => p.value.trim()).forEach((p, i) => {
personalInfo[`pi${i + 1}`] = {
kind: p.kind as ContactPersonalInfo['kind'],
value: p.value.trim(),
...(p.level ? { level: p.level as 'high' | 'medium' | 'low' } : {}),
};
});
const notes: Record<string, ContactNote> = {};
form.notes.forEach((n, i) => {
if (n.note.trim()) notes[`n${i + 1}`] = { note: n.note.trim() };
});
const keywords: Record<string, boolean> = {};
form.keywords.forEach((k) => {
if (k.trim()) keywords[k.trim()] = true;
});
const media: Record<string, ContactMedia> = {};
if (form.photoUri.trim()) {
media.photo = {
kind: 'photo',
uri: form.photoUri.trim(),
...(form.photoMediaType ? { mediaType: form.photoMediaType } : {}),
};
}
const speakToAs =
form.grammaticalGender || form.pronouns.trim()
? {
...(form.grammaticalGender ? { grammaticalGender: form.grammaticalGender } : {}),
...(form.pronouns.trim()
? { pronouns: { p0: { pronouns: form.pronouns.trim() } } }
: {}),
}
: undefined;
return {
...(name ? { name } : {}),
...(nicknames ? { nicknames } : {}),
...(Object.keys(emails).length ? { emails } : {}),
...(Object.keys(phones).length ? { phones } : {}),
...(Object.keys(addresses).length ? { addresses } : {}),
...(Object.keys(organizations).length ? { organizations } : {}),
...(Object.keys(titles).length ? { titles } : {}),
...(Object.keys(anniversaries).length ? { anniversaries } : {}),
...(Object.keys(onlineServices).length ? { onlineServices } : {}),
...(Object.keys(personalInfo).length ? { personalInfo } : {}),
...(Object.keys(notes).length ? { notes } : {}),
...(Object.keys(keywords).length ? { keywords } : {}),
...(speakToAs ? { speakToAs } : {}),
...(form.calendarUri.trim() ? { calendarUri: form.calendarUri.trim() } : {}),
...(form.schedulingUri.trim() ? { schedulingUri: form.schedulingUri.trim() } : {}),
...(form.freeBusyUri.trim() ? { freeBusyUri: form.freeBusyUri.trim() } : {}),
media,
};
}
const EMAIL_CONTEXTS: Array<{ value: string; label: string }> = [
{ value: '', label: 'None' },
{ value: 'work', label: 'Work' },
{ value: 'private', label: 'Private' },
];
const PHONE_CONTEXTS = EMAIL_CONTEXTS;
const ADDRESS_CONTEXTS: Array<{ value: string; label: string }> = [
{ value: '', label: 'None' },
{ value: 'work', label: 'Work' },
{ value: 'private', label: 'Private' },
];
const PHONE_FEATURES: Array<{ value: string; label: string }> = [
{ value: '', label: 'Phone' },
{ value: 'voice', label: 'Voice' },
{ value: 'cell', label: 'Mobile' },
{ value: 'fax', label: 'Fax' },
{ value: 'pager', label: 'Pager' },
{ value: 'video', label: 'Video' },
{ value: 'text', label: 'Text' },
];
const ANNIVERSARY_KINDS: Array<{ value: string; label: string }> = [
{ value: 'birth', label: 'Birthday' },
{ value: 'wedding', label: 'Anniversary' },
{ value: 'death', label: 'Memorial' },
{ value: 'other', label: 'Other' },
];
const PERSONAL_INFO_KINDS: Array<{ value: string; label: string }> = [
{ value: 'hobby', label: 'Hobby' },
{ value: 'expertise', label: 'Expertise' },
{ value: 'interest', label: 'Interest' },
{ value: 'other', label: 'Other' },
];
const PERSONAL_INFO_LEVELS: Array<{ value: string; label: string }> = [
{ value: '', label: '–' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
];
const GENDER_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: 'Unspecified' },
{ value: 'masculine', label: 'Masculine' },
{ value: 'feminine', label: 'Feminine' },
{ value: 'common', label: 'Common' },
{ value: 'neuter', label: 'Neuter' },
{ value: 'animate', label: 'Animate' },
{ value: 'inanimate', label: 'Inanimate' },
];
function Pills({
value, options, onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (v: string) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.pillRow}>
{options.map((o) => {
const active = value === o.value;
return (
<Pressable
key={o.value}
onPress={() => onChange(o.value)}
style={[styles.pill, active && styles.pillActive]}
>
<Text style={[styles.pillText, active && styles.pillTextActive]}>{o.label}</Text>
</Pressable>
);
})}
</View>
);
}
function Section({
icon, label, children,
collapsible = false, defaultOpen = true,
}: {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [open, setOpen] = React.useState(defaultOpen);
const isOpen = collapsible ? open : true;
return (
<View style={styles.section}>
<Pressable
onPress={() => collapsible && setOpen((o) => !o)}
style={styles.sectionHeader}
disabled={!collapsible}
>
<View style={styles.sectionIcon}>{icon}</View>
<Text style={styles.sectionLabel}>{label}</Text>
{collapsible && (
isOpen
? <ChevronDown size={14} color={c.textMuted} />
: <ChevronRight size={14} color={c.textMuted} />
)}
</Pressable>
{isOpen && <View style={styles.sectionBody}>{children}</View>}
</View>
);
}
function Field({ label, children }: { label?: string; children: React.ReactNode }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.field}>
{!!label && <Text style={styles.fieldLabel}>{label}</Text>}
{children}
</View>
);
}
function RemovableRow({
onRemove, children,
}: {
onRemove: () => void;
children: React.ReactNode;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.removableRow}>
<View style={{ flex: 1 }}>{children}</View>
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
<XIcon size={14} color={c.textMuted} />
</Pressable>
</View>
);
}
function AddButton({ onPress, label }: { onPress: () => void; label: string }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable onPress={onPress} style={({ pressed }) => [styles.addBtn, pressed && styles.addBtnPressed]}>
<Plus size={14} color={c.primary} />
<Text style={styles.addBtnLabel}>{label}</Text>
</Pressable>
);
}
export default function ContactFormScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const { contactId, addressBookId: initialBook, asGroup } = route.params || {};
const isEdit = !!contactId;
const addressBooks = useContactsStore((s) => s.addressBooks);
const allContacts = useContactsStore((s) => s.contacts);
const createContact = useContactsStore((s) => s.createContact);
const updateContact = useContactsStore((s) => s.updateContact);
const existing = React.useMemo(
() => (contactId ? allContacts.find((c) => c.id === contactId) : undefined),
[allContacts, contactId],
);
const existingKeywords = React.useMemo(() => {
const counts = new Map<string, number>();
for (const contact of allContacts) {
for (const kw of getContactKeywords(contact)) {
counts.set(kw, (counts.get(kw) || 0) + 1);
}
}
return Array.from(counts.entries())
.map(([keyword, count]) => ({ keyword, count }))
.sort((a, b) => a.keyword.localeCompare(b.keyword));
}, [allContacts]);
const [form, setForm] = React.useState<FormState>(() => {
if (existing) return contactToForm(existing);
const init = blankForm();
if (initialBook) init.addressBookId = initialBook;
else if (addressBooks[0]) init.addressBookId = addressBooks[0].id;
return init;
});
const [keywordInput, setKeywordInput] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [confirmDiscard, setConfirmDiscard] = React.useState(false);
const [datePickerIndex, setDatePickerIndex] = React.useState<number | null>(null);
React.useEffect(() => {
if (!form.addressBookId && addressBooks[0]) {
setForm((f) => ({ ...f, addressBookId: addressBooks[0].id }));
}
}, [addressBooks, form.addressBookId]);
const updateForm = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setDirty(true);
setForm((f) => ({ ...f, [key]: value }));
};
const handleBack = () => {
if (dirty) setConfirmDiscard(true);
else navigation.goBack();
};
const handleSave = async () => {
const hasName = form.given || form.surname || form.full;
const hasEmail = form.emails.some((e) => e.address.trim());
if (!hasName && !hasEmail) {
Alert.alert('Missing info', 'Add a name or at least one email.');
return;
}
if (!form.addressBookId) {
Alert.alert('Missing address book', 'Select an address book to save this contact.');
return;
}
// Email validity
for (const e of form.emails) {
if (e.address.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.address.trim())) {
Alert.alert('Invalid email', `"${e.address}" is not a valid email address.`);
return;
}
}
setSaving(true);
try {
const patch = formToPatch(form);
if (asGroup) patch.kind = 'group';
if (isEdit && existing) {
await updateContact(existing.id, patch);
} else {
await createContact(patch, form.addressBookId);
}
navigation.goBack();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setSaving(false);
}
};
const headerTitle = isEdit
? `Edit ${existing ? getContactDisplayName(existing) : 'Contact'}`
: asGroup ? 'New Group' : 'New Contact';
const previewName = [form.given, form.surname].filter(Boolean).join(' ').trim();
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{headerTitle}</Text>
<Pressable onPress={handleSave} disabled={saving} style={styles.saveBtn} hitSlop={8}>
{saving ? (
<Text style={styles.saveLabel}>Saving…</Text>
) : (
<>
<Check size={16} color={c.primaryForeground} />
<Text style={styles.saveLabel}>Save</Text>
</>
)}
</Pressable>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{/* Photo + Name preview */}
<View style={styles.photoPanel}>
<Pressable
onPress={async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Photo access needed', 'Grant photo library permission to pick a contact photo.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
const mime = asset.mimeType || 'image/jpeg';
if (asset.base64) {
updateForm('photoUri', `data:${mime};base64,${asset.base64}`);
} else {
updateForm('photoUri', asset.uri);
}
updateForm('photoMediaType', mime);
}}
style={styles.photoBtn}
>
{form.photoUri ? (
<Image source={{ uri: form.photoUri }} style={styles.photoThumb} />
) : (
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
<Camera size={28} color={c.textMuted} />
</View>
)}
</Pressable>
<View style={{ flex: 1 }}>
<Text style={styles.previewName} numberOfLines={1}>
{previewName || (asGroup ? 'New group' : 'New contact')}
</Text>
<Text style={styles.previewHint}>Tap photo to choose an image</Text>
{form.photoUri ? (
<Pressable
onPress={() => {
updateForm('photoUri', '');
updateForm('photoMediaType', '');
}}
style={styles.removePhotoBtn}
hitSlop={8}
>
<Text style={styles.removePhotoLabel}>Remove photo</Text>
</Pressable>
) : null}
</View>
</View>
{/* Address book */}
{addressBooks.length > 1 && (
<Section icon={<Book size={16} color={c.textMuted} />} label="Address Book">
<View style={styles.pillRow}>
{addressBooks.map((book) => (
<Pressable
key={book.id}
onPress={() => updateForm('addressBookId', book.id)}
style={[styles.pill, form.addressBookId === book.id && styles.pillActive]}
>
<Text style={[styles.pillText, form.addressBookId === book.id && styles.pillTextActive]}>
{book.name}
</Text>
</Pressable>
))}
</View>
</Section>
)}
{/* Identity */}
<Section icon={<User size={16} color={c.textMuted} />} label="Identity">
<View style={styles.row2}>
<Field label="Prefix">
<TextInput
style={styles.input}
placeholder="Mr."
placeholderTextColor={c.textMuted}
value={form.prefix}
onChangeText={(v) => updateForm('prefix', v)}
/>
</Field>
<Field label="Suffix">
<TextInput
style={styles.input}
placeholder="Jr."
placeholderTextColor={c.textMuted}
value={form.suffix}
onChangeText={(v) => updateForm('suffix', v)}
/>
</Field>
</View>
<Field label="First name">
<TextInput
style={styles.input}
placeholder="First name"
placeholderTextColor={c.textMuted}
value={form.given}
onChangeText={(v) => updateForm('given', v)}
/>
</Field>
<Field label="Middle name">
<TextInput
style={styles.input}
placeholder="Middle"
placeholderTextColor={c.textMuted}
value={form.middle}
onChangeText={(v) => updateForm('middle', v)}
/>
</Field>
<Field label="Last name">
<TextInput
style={styles.input}
placeholder="Last name"
placeholderTextColor={c.textMuted}
value={form.surname}
onChangeText={(v) => updateForm('surname', v)}
/>
</Field>
<Field label="Nickname">
<TextInput
style={styles.input}
placeholder="Nickname"
placeholderTextColor={c.textMuted}
value={form.nickname}
onChangeText={(v) => updateForm('nickname', v)}
/>
</Field>
<Field label="Display name (optional)">
<TextInput
style={styles.input}
placeholder="Custom display name"
placeholderTextColor={c.textMuted}
value={form.full}
onChangeText={(v) => updateForm('full', v)}
/>
</Field>
</Section>
{/* Email */}
<Section icon={<Mail size={16} color={c.textMuted} />} label="Email">
{form.emails.map((e, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('emails', form.emails.filter((_, idx) => idx !== i))}
>
<TextInput
style={styles.input}
placeholder="email@example.com"
placeholderTextColor={c.textMuted}
keyboardType="email-address"
autoCapitalize="none"
value={e.address}
onChangeText={(v) => {
const next = [...form.emails];
next[i] = { ...e, address: v };
updateForm('emails', next);
}}
/>
<Pills
value={e.context}
options={EMAIL_CONTEXTS}
onChange={(v) => {
const next = [...form.emails];
next[i] = { ...e, context: v };
updateForm('emails', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add email"
onPress={() => updateForm('emails', [...form.emails, { address: '', context: '' }])}
/>
</Section>
{/* Phone */}
<Section icon={<Phone size={16} color={c.textMuted} />} label="Phone">
{form.phones.map((p, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('phones', form.phones.filter((_, idx) => idx !== i))}
>
<TextInput
style={styles.input}
placeholder="+1 555 0100"
placeholderTextColor={c.textMuted}
keyboardType="phone-pad"
value={p.number}
onChangeText={(v) => {
const next = [...form.phones];
next[i] = { ...p, number: v };
updateForm('phones', next);
}}
/>
<Pills
value={p.feature}
options={PHONE_FEATURES}
onChange={(v) => {
const next = [...form.phones];
next[i] = { ...p, feature: v };
updateForm('phones', next);
}}
/>
<Pills
value={p.context}
options={PHONE_CONTEXTS}
onChange={(v) => {
const next = [...form.phones];
next[i] = { ...p, context: v };
updateForm('phones', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add phone"
onPress={() => updateForm('phones', [...form.phones, { number: '', context: '', feature: '' }])}
/>
</Section>
{/* Work */}
<Section
icon={<Building size={16} color={c.textMuted} />}
label="Work"
collapsible
defaultOpen={form.orgs.length > 0}
>
{form.orgs.map((o, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('orgs', form.orgs.filter((_, idx) => idx !== i))}
>
<Field label="Organization">
<TextInput
style={styles.input}
placeholder="Company"
placeholderTextColor={c.textMuted}
value={o.name}
onChangeText={(v) => {
const next = [...form.orgs];
next[i] = { ...o, name: v };
updateForm('orgs', next);
}}
/>
</Field>
<Field label="Department">
<TextInput
style={styles.input}
placeholder="Department"
placeholderTextColor={c.textMuted}
value={o.department}
onChangeText={(v) => {
const next = [...form.orgs];
next[i] = { ...o, department: v };
updateForm('orgs', next);
}}
/>
</Field>
<Field label="Job title">
<TextInput
style={styles.input}
placeholder="Title"
placeholderTextColor={c.textMuted}
value={o.jobTitle}
onChangeText={(v) => {
const next = [...form.orgs];
next[i] = { ...o, jobTitle: v };
updateForm('orgs', next);
}}
/>
</Field>
<Field label="Role">
<TextInput
style={styles.input}
placeholder="Role"
placeholderTextColor={c.textMuted}
value={o.role}
onChangeText={(v) => {
const next = [...form.orgs];
next[i] = { ...o, role: v };
updateForm('orgs', next);
}}
/>
</Field>
</RemovableRow>
))}
<AddButton
label="Add organization"
onPress={() => updateForm('orgs', [...form.orgs, { name: '', department: '', jobTitle: '', role: '' }])}
/>
</Section>
{/* Address */}
<Section
icon={<MapPin size={16} color={c.textMuted} />}
label="Address"
collapsible
defaultOpen={form.addresses.length > 0}
>
{form.addresses.map((a, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('addresses', form.addresses.filter((_, idx) => idx !== i))}
>
<TextInput
style={styles.input}
placeholder="Street"
placeholderTextColor={c.textMuted}
value={a.street}
onChangeText={(v) => {
const next = [...form.addresses];
next[i] = { ...a, street: v };
updateForm('addresses', next);
}}
/>
<View style={styles.row2}>
<Field>
<TextInput
style={styles.input}
placeholder="City"
placeholderTextColor={c.textMuted}
value={a.locality}
onChangeText={(v) => {
const next = [...form.addresses];
next[i] = { ...a, locality: v };
updateForm('addresses', next);
}}
/>
</Field>
<Field>
<TextInput
style={styles.input}
placeholder="Region"
placeholderTextColor={c.textMuted}
value={a.region}
onChangeText={(v) => {
const next = [...form.addresses];
next[i] = { ...a, region: v };
updateForm('addresses', next);
}}
/>
</Field>
</View>
<View style={styles.row2}>
<Field>
<TextInput
style={styles.input}
placeholder="Postcode"
placeholderTextColor={c.textMuted}
value={a.postcode}
onChangeText={(v) => {
const next = [...form.addresses];
next[i] = { ...a, postcode: v };
updateForm('addresses', next);
}}
/>
</Field>
<Field>
<TextInput
style={styles.input}
placeholder="Country"
placeholderTextColor={c.textMuted}
value={a.country}
onChangeText={(v) => {
const next = [...form.addresses];
next[i] = { ...a, country: v };
updateForm('addresses', next);
}}
/>
</Field>
</View>
<Pills
value={a.context}
options={ADDRESS_CONTEXTS}
onChange={(v) => {
const next = [...form.addresses];
next[i] = { ...a, context: v };
updateForm('addresses', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add address"
onPress={() =>
updateForm('addresses', [...form.addresses, {
street: '', locality: '', region: '', postcode: '', country: '', context: '',
}])
}
/>
</Section>
{/* Online services */}
<Section
icon={<Globe size={16} color={c.textMuted} />}
label="Online"
collapsible
defaultOpen={form.online.length > 0}
>
{form.online.map((s, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('online', form.online.filter((_, idx) => idx !== i))}
>
<Field label="URL">
<TextInput
style={styles.input}
placeholder="https://example.com/handle"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={s.uri}
onChangeText={(v) => {
const next = [...form.online];
next[i] = { ...s, uri: v };
updateForm('online', next);
}}
/>
</Field>
<Field label="Service">
<TextInput
style={styles.input}
placeholder="LinkedIn, Mastodon, …"
placeholderTextColor={c.textMuted}
value={s.service}
onChangeText={(v) => {
const next = [...form.online];
next[i] = { ...s, service: v };
updateForm('online', next);
}}
/>
</Field>
</RemovableRow>
))}
<AddButton
label="Add link"
onPress={() => updateForm('online', [...form.online, { uri: '', service: '' }])}
/>
</Section>
{/* Anniversaries */}
<Section
icon={<Cake size={16} color={c.textMuted} />}
label="Important dates"
collapsible
defaultOpen={form.anniversaries.length > 0}
>
{form.anniversaries.map((a, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('anniversaries', form.anniversaries.filter((_, idx) => idx !== i))}
>
<View style={styles.dateInputRow}>
<TextInput
style={[styles.input, { flex: 1 }]}
placeholder="YYYY-MM-DD or --MM-DD"
placeholderTextColor={c.textMuted}
value={a.date}
onChangeText={(v) => {
const next = [...form.anniversaries];
next[i] = { ...a, date: v };
updateForm('anniversaries', next);
}}
/>
<Pressable
onPress={() => setDatePickerIndex(i)}
style={styles.datePickerBtn}
hitSlop={8}
>
<Calendar size={18} color={c.primary} />
</Pressable>
</View>
<Pills
value={a.kind}
options={ANNIVERSARY_KINDS}
onChange={(v) => {
const next = [...form.anniversaries];
next[i] = { ...a, kind: v };
updateForm('anniversaries', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add date"
onPress={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
/>
</Section>
{/* Personal info */}
<Section
icon={<Heart size={16} color={c.textMuted} />}
label="Personal info"
collapsible
defaultOpen={form.personalInfo.length > 0}
>
{form.personalInfo.map((pi, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('personalInfo', form.personalInfo.filter((_, idx) => idx !== i))}
>
<TextInput
style={styles.input}
placeholder="Hiking, JavaScript, …"
placeholderTextColor={c.textMuted}
value={pi.value}
onChangeText={(v) => {
const next = [...form.personalInfo];
next[i] = { ...pi, value: v };
updateForm('personalInfo', next);
}}
/>
<Pills
value={pi.kind}
options={PERSONAL_INFO_KINDS}
onChange={(v) => {
const next = [...form.personalInfo];
next[i] = { ...pi, kind: v };
updateForm('personalInfo', next);
}}
/>
<Pills
value={pi.level}
options={PERSONAL_INFO_LEVELS}
onChange={(v) => {
const next = [...form.personalInfo];
next[i] = { ...pi, level: v };
updateForm('personalInfo', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add"
onPress={() => updateForm('personalInfo', [...form.personalInfo, { kind: 'hobby', level: '', value: '' }])}
/>
</Section>
{/* Gender */}
<Section
icon={<UserCircle size={16} color={c.textMuted} />}
label="Gender"
collapsible
defaultOpen={!!(form.grammaticalGender || form.pronouns)}
>
<Field label="Grammatical gender">
<Pills
value={form.grammaticalGender}
options={GENDER_OPTIONS}
onChange={(v) => updateForm('grammaticalGender', v)}
/>
</Field>
<Field label="Pronouns">
<TextInput
style={styles.input}
placeholder="they/them"
placeholderTextColor={c.textMuted}
value={form.pronouns}
onChangeText={(v) => updateForm('pronouns', v)}
/>
</Field>
</Section>
{/* Calendar */}
<Section
icon={<Calendar size={16} color={c.textMuted} />}
label="Calendar"
collapsible
defaultOpen={!!(form.calendarUri || form.schedulingUri || form.freeBusyUri)}
>
<Field label="Calendar URI">
<TextInput
style={styles.input}
placeholder="https://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.calendarUri}
onChangeText={(v) => updateForm('calendarUri', v)}
/>
</Field>
<Field label="Scheduling URI">
<TextInput
style={styles.input}
placeholder="https://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.schedulingUri}
onChangeText={(v) => updateForm('schedulingUri', v)}
/>
</Field>
<Field label="Free/Busy URI">
<TextInput
style={styles.input}
placeholder="https://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.freeBusyUri}
onChangeText={(v) => updateForm('freeBusyUri', v)}
/>
</Field>
</Section>
{/* Categories */}
<Section
icon={<Tag size={16} color={c.textMuted} />}
label="Categories"
collapsible
defaultOpen={form.keywords.length > 0}
>
{form.keywords.length > 0 && (
<View style={styles.chipRow}>
{form.keywords.map((kw) => (
<Pressable
key={kw}
onPress={() => updateForm('keywords', form.keywords.filter((k) => k !== kw))}
style={styles.keywordChip}
>
<Text style={styles.keywordChipText}>{kw}</Text>
<XIcon size={11} color={c.primary} />
</Pressable>
))}
</View>
)}
<TextInput
style={styles.input}
placeholder="Add tag and press Enter"
placeholderTextColor={c.textMuted}
value={keywordInput}
onChangeText={setKeywordInput}
onSubmitEditing={() => {
const k = keywordInput.trim();
if (k && !form.keywords.includes(k)) {
updateForm('keywords', [...form.keywords, k]);
}
setKeywordInput('');
}}
returnKeyType="done"
/>
{existingKeywords.length > 0 && (
<View style={styles.chipRow}>
{existingKeywords
.filter((k) => !form.keywords.includes(k.keyword))
.slice(0, 8)
.map((k) => (
<Pressable
key={k.keyword}
onPress={() => updateForm('keywords', [...form.keywords, k.keyword])}
style={styles.suggestedChip}
>
<Text style={styles.suggestedChipText}>+ {k.keyword}</Text>
</Pressable>
))}
</View>
)}
</Section>
{/* Notes */}
<Section
icon={<FileText size={16} color={c.textMuted} />}
label="Notes"
collapsible
defaultOpen={form.notes.length > 0}
>
{form.notes.map((n, i) => (
<RemovableRow
key={i}
onRemove={() => updateForm('notes', form.notes.filter((_, idx) => idx !== i))}
>
<TextInput
style={[styles.input, styles.multiline]}
placeholder="Notes"
placeholderTextColor={c.textMuted}
multiline
value={n.note}
onChangeText={(v) => {
const next = [...form.notes];
next[i] = { note: v };
updateForm('notes', next);
}}
/>
</RemovableRow>
))}
<AddButton
label="Add note"
onPress={() => updateForm('notes', [...form.notes, { note: '' }])}
/>
</Section>
</ScrollView>
</KeyboardAvoidingView>
<Dialog
visible={confirmDiscard}
title="Discard changes?"
message="You have unsaved changes. Discard them?"
variant="destructive"
confirmText="Discard"
onConfirm={() => {
setConfirmDiscard(false);
navigation.goBack();
}}
onCancel={() => setConfirmDiscard(false)}
/>
{datePickerIndex !== null && (() => {
const draft = form.anniversaries[datePickerIndex];
const parsed = draft ? parseDateDraft(draft.date) : new Date();
const onChange = (event: DateTimePickerEvent, selected?: Date) => {
if (Platform.OS === 'android') {
setDatePickerIndex(null);
}
if (event.type === 'dismissed' || !selected) return;
const iso = formatDateAsISO(selected);
const next = [...form.anniversaries];
if (next[datePickerIndex]) {
next[datePickerIndex] = { ...next[datePickerIndex], date: iso };
updateForm('anniversaries', next);
}
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerIndex(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setDatePickerIndex(null)} hitSlop={8}>
<Text style={styles.pickerDone}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={parsed}
mode="date"
display="spinner"
onChange={onChange}
/>
</View>
</Modal>
);
}
return (
<DateTimePicker
value={parsed}
mode="date"
display="default"
onChange={onChange}
/>
);
})()}
</SafeAreaView>
);
}
function parseDateDraft(s: string): Date {
const trimmed = s.trim();
const fullMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
if (fullMatch) {
const y = parseInt(fullMatch[1], 10);
const m = parseInt(fullMatch[2], 10) - 1;
const d = parseInt(fullMatch[3], 10);
return new Date(y, m, d);
}
const partial = /^--(\d{2})-?(\d{2})?$/.exec(trimmed);
if (partial) {
const now = new Date();
const m = parseInt(partial[1], 10) - 1;
const d = partial[2] ? parseInt(partial[2], 10) : 1;
return new Date(now.getFullYear(), m, d);
}
return new Date();
}
function formatDateAsISO(d: Date): string {
const y = String(d.getFullYear()).padStart(4, '0');
const m = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${da}`;
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
scrollContent: {
paddingVertical: spacing.md,
paddingBottom: spacing.xxxl * 2,
paddingHorizontal: spacing.lg,
gap: spacing.lg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
height: 36,
backgroundColor: c.primary,
borderRadius: radius.full,
},
saveLabel: { ...typography.bodyMedium, color: c.primaryForeground },
photoPanel: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
},
photoBtn: { borderRadius: 999 },
photoThumb: {
width: 80, height: 80,
borderRadius: 40,
backgroundColor: c.surface,
},
photoPlaceholder: {
alignItems: 'center', justifyContent: 'center',
borderWidth: 1, borderColor: c.border, borderStyle: 'dashed',
},
previewName: { ...typography.h3, color: c.text },
previewHint: { ...typography.caption, color: c.textMuted, marginTop: 2 },
removePhotoBtn: { marginTop: spacing.xs },
removePhotoLabel: { ...typography.caption, color: c.error },
section: { gap: spacing.sm },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
sectionIcon: { width: 16 },
sectionLabel: {
flex: 1,
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
fontSize: 11,
letterSpacing: 0.6,
},
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
field: { gap: 4 },
fieldLabel: { ...typography.caption, color: c.textMuted },
row2: { flexDirection: 'row', gap: spacing.sm },
input: {
minHeight: componentSizes.inputHeight,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
...typography.body,
color: c.text,
},
multiline: {
minHeight: 80,
paddingVertical: spacing.sm,
textAlignVertical: 'top',
},
pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
pill: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: c.border,
},
pillActive: { backgroundColor: c.primary, borderColor: c.primary },
pillText: { ...typography.caption, color: c.textSecondary },
pillTextActive: { color: c.primaryForeground },
removableRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs,
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: c.borderLight,
paddingTop: spacing.sm,
},
removeBtn: {
width: 26, height: 26,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: c.surface,
marginTop: 6,
},
addBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
addBtnPressed: { backgroundColor: c.surfaceHover },
addBtnLabel: { ...typography.caption, color: c.primary },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
keywordChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
keywordChipText: { ...typography.caption, color: c.primary },
suggestedChip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: c.border,
},
suggestedChipText: { ...typography.caption, color: c.textSecondary },
dateInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
datePickerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
pickerOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
pickerSheet: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingBottom: spacing.lg,
},
pickerHeader: {
flexDirection: 'row',
justifyContent: 'flex-end',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
pickerDone: { ...typography.bodySemibold, color: c.primary },
});
}