fix: translate the contacts screens

The contact list, detail and edit screens, the group screen, the
import, picker and tag sheets and the contacts drawer were written in
English, including the activity panel ("Unknown", "Upcoming events"),
alert titles and every form label and option. They now go through
t(), reusing the webmail's contacts.* keys where the meaning matches,
with ICU plurals for member, contact and import counts instead of
`contact${n === 1 ? '' : 's'}`. JSContact context, phone feature,
personal-info and gender values on the detail screen are shown with
their translated labels instead of the raw keys.

Icon-only buttons on these screens (back, edit, more, bulk actions,
remove, close, clear search) get screen reader labels, and chips,
checkboxes and collapsible sections report their state.
This commit is contained in:
Linus Rath
2026-09-24 20:49:55 +02:00
parent cb5c152cb3
commit d59c571d93
13 changed files with 845 additions and 364 deletions
+94 -2
View File
@@ -110,12 +110,104 @@
"address_books": {
"confirm_delete_count": "{count, plural, =0 {Delete \"{name}\"? This cannot be undone.} one {Delete \"{name}\"? Its # contact will be deleted with it. This cannot be undone.} other {Delete \"{name}\"? Its # contacts will be deleted with it. This cannot be undone.}}"
},
"all_contacts": "All Contacts",
"bulk": {
"add_tag": "Add tag",
"delete_confirm_mobile": "{count, plural, one {Delete # contact? This cannot be undone.} other {Delete # contacts? This cannot be undone.}}",
"move_to_address_book": "Move to address book",
"tag_failed": "Tagging failed"
},
"contact": "Contact",
"create_hint_mobile": "Long-press to create a group instead",
"detail": {
"add_to_group_failed": "Add to group failed",
"copy_name": "{name} (Copy)",
"created_on": "Created {date}",
"duplicate_failed": "Duplicate failed",
"duplicate_no_book": "Cannot duplicate without an address book.",
"no_address_book": "No address book",
"not_found": "Contact not found",
"open_dialer_failed": "Cannot open dialer",
"open_link_failed": "Cannot open link",
"open_maps_failed": "Cannot open maps",
"open_sms_failed": "Cannot open messaging app",
"share": "Share",
"share_title": "Share {name}",
"sms": "SMS",
"timezone_value": "Timezone: {zone}",
"updated_on": "Updated {date}"
},
"empty_state_hint_mobile": "Tap + to add one",
"export": {
"failed": "Export failed"
},
"form": {
"add_nickname": "Add nickname",
"add_note": "Add note",
"add_organization": "Add organization",
"address_book_required": "Select an address book to save this contact.",
"categories_placeholder_mobile": "Add tag and press Enter",
"context_none": "None",
"date_placeholder": "YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD",
"display_name_optional": "Display name (optional)",
"display_name_placeholder": "Custom display name",
"edit_named": "Edit {name}",
"gender_unspecified": "Unspecified",
"grammatical_gender": "Grammatical gender",
"invalid_date_message": "\"{date}\" is not a date. Use YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD.",
"invalid_date_title": "Invalid date",
"invalid_email_message": "\"{address}\" is not a valid email address.",
"invalid_email_title": "Invalid email",
"label": "Label",
"label_placeholder": "Work, Personal, …",
"missing_address_book": "Missing address book",
"missing_info": "Missing info",
"name_or_email_required": "Add a name or at least one email.",
"new_organization": "New organization",
"org_name_required": "Add the organization name.",
"photo_hint_mobile": "Tap photo to choose an image",
"photo_permission_message": "Grant photo library permission to pick a contact photo.",
"photo_permission_title": "Photo access needed",
"pick_date": "Pick a date",
"pronouns": "Pronouns",
"pronouns_placeholder": "they/them",
"url": "URL"
},
"group": "Group",
"groups": {
"add_member": "Add member",
"add_members": "Add members",
"add_members_failed": "Failed to add members",
"create_ellipsis": "New group…",
"delete_confirm_named": "Delete \"{name}\"? Members will not be deleted.",
"delete_failed": "Failed to delete group",
"email_all": "Email all",
"members_with_count": "Members ({count})",
"no_emails": "No emails",
"no_members_hint": "Tap \"Add member\" to get started",
"not_found": "Group not found",
"remove_member": "Remove member",
"remove_member_confirm": "Remove \"{name}\" from this group?",
"remove_member_failed": "Failed to remove member"
},
"import": {
"into_address_book": "Import into address book"
}
"change_target": "Change",
"choose_file_mobile": "Choose a vCard file",
"failed_count": "{count, plural, one {# contact could not be imported.} other {# contacts could not be imported.}}",
"import_count": "Import {count}",
"into_address_book": "Import into address book",
"into_named": "Import into {name}",
"no_address_book": "Create an address book before importing contacts.",
"picker_failed": "Could not open the file picker"
},
"no_tags_hint": "No tags yet. Type one above.",
"picker": {
"add_count": "{count, plural, =0 {Add} other {Add (#)}}",
"title": "Add Contact"
},
"rename_tag_hint_mobile": "Long-press a tag to rename it",
"tag_name_placeholder": "New tag name",
"unnamed": "Unnamed"
},
"context_menu": {
"color_tag": "Tag",
@@ -0,0 +1,36 @@
// Contacts screens and sheets whose every user-visible string goes through
// t(). A literal English label, alert or JSX text sneaking back in stays
// English in every locale, so this scans the source for them.
import { describe, it, expect } from 'vitest';
import { join } from 'node:path';
import { findUntranslatedLiterals } from './untranslated-literals';
const SRC = join(__dirname, '..', '..');
// File (relative to src/) → texts that stay as they are on purpose.
const TRANSLATED_FILES: Record<string, string[]> = {
'screens/ContactsScreen.tsx': [],
'screens/ContactDetailScreen.tsx': [],
// Service names are product names.
'screens/ContactFormScreen.tsx': ['LinkedIn, Mastodon, …'],
'screens/GroupDetailScreen.tsx': [],
'components/contacts/ContactActivity.tsx': [],
'components/contacts/ContactImportSheet.tsx': [
// Only reachable in a development build without the native module.
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
],
'components/contacts/ContactsSidebarDrawer.tsx': [],
'components/contacts/AddressBookPickerSheet.tsx': [],
'components/contacts/ContactPickerSheet.tsx': [],
'components/contacts/TagAssignSheet.tsx': [],
'components/contacts/ContactListRow.tsx': [],
};
describe('translated contacts screens', () => {
for (const [file, allow] of Object.entries(TRANSLATED_FILES)) {
it(`${file} has no hard-coded English text`, () => {
expect(findUntranslatedLiterals(join(SRC, file), allow)).toEqual([]);
});
}
});
@@ -8,6 +8,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
interface Props {
visible: boolean;
@@ -19,9 +20,10 @@ interface Props {
}
export default function AddressBookPickerSheet({
visible, onClose, currentBookId, onPick, title = 'Move to address book',
visible, onClose, currentBookId, onPick, title,
}: Props) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const addressBooks = useContactsStore((s) => s.addressBooks);
@@ -61,7 +63,7 @@ export default function AddressBookPickerSheet({
setNewName('');
onPick(book.id);
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.address_books.create_failed', 'Failed to create address book'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
} finally {
setBusy(false);
}
@@ -83,8 +85,14 @@ export default function AddressBookPickerSheet({
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<Text style={styles.title}>{title ?? t('contacts.bulk.move_to_address_book', 'Move to address book')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
@@ -100,6 +108,8 @@ export default function AddressBookPickerSheet({
key={book.id}
onPress={canTarget ? () => onPick(book.id) : undefined}
disabled={!canTarget}
accessibilityRole="button"
accessibilityState={{ disabled: !canTarget, selected: isCurrent }}
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
>
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
@@ -115,7 +125,7 @@ export default function AddressBookPickerSheet({
<View style={styles.createRow}>
<TextInput
style={styles.createInput}
placeholder="New address book name"
placeholder={t('contacts.address_books.name_label', 'Address book name')}
placeholderTextColor={c.textMuted}
value={newName}
onChangeText={setNewName}
@@ -128,6 +138,8 @@ export default function AddressBookPickerSheet({
disabled={!newName.trim() || busy}
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('contacts.address_books.create', 'New address book')}
>
<Check size={16} color={c.primaryForeground} />
</Pressable>
@@ -136,9 +148,10 @@ export default function AddressBookPickerSheet({
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<Plus size={16} color={c.primary} />
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
<Text style={[styles.rowLabel, { color: c.primary }]}>{t('contacts.address_books.create', 'New address book')}</Text>
</Pressable>
)}
</ScrollView>
+11 -11
View File
@@ -9,7 +9,7 @@ import type { RootStackParamList } from '../../navigation/types';
import { getEmails, queryEmailsByFilter } from '../../api/email';
import { getEvents, queryEvents } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
import { getEventColor } from '../../lib/calendar-utils';
import { hasCalendarCapability } from '../../lib/capabilities';
import { singleLine } from '../../lib/single-line';
@@ -76,8 +76,8 @@ function formatRelativeDate(iso: string): string {
}
}
function formatEventTime(event: CalendarEvent): string {
if (event.showWithoutTime) return 'All day';
function formatEventTime(event: CalendarEvent, t: TranslateFn): string {
if (event.showWithoutTime) return t('contacts.activity.all_day', 'All day');
try {
return format(parseISO(event.start), 'HH:mm');
} catch {
@@ -189,7 +189,7 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.sectionIcon}>
<MailIcon size={16} color={c.textMuted} />
</View>
<Text style={styles.sectionLabel}>Recent emails</Text>
<Text style={styles.sectionLabel}>{t('contacts.activity.recent_emails', 'Recent Emails')}</Text>
</View>
<View style={styles.sectionBody}>
{emailsLoading ? (
@@ -197,13 +197,13 @@ export function ContactActivity({ contact }: Props) {
<ActivityIndicator size="small" color={c.primary} />
</View>
) : emailsError ? (
<Text style={styles.emptyText}>Couldn't load emails.</Text>
<Text style={styles.emptyText}>{t('contacts.activity.load_failed', 'Failed to load')}</Text>
) : !emails || emails.length === 0 ? (
<Text style={styles.emptyText}>No emails with this contact.</Text>
<Text style={styles.emptyText}>{t('contacts.activity.no_emails', 'No recent emails')}</Text>
) : (
emails.map((email) => {
const sender = email.from?.[0];
const senderName = sender?.name || sender?.email || 'Unknown';
const senderName = sender?.name || sender?.email || t('contacts.activity.unknown_sender', 'Unknown sender');
const preview = singleLine(email.preview);
return (
<Pressable
@@ -243,21 +243,21 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.sectionIcon}>
<CalendarDays size={16} color={c.textMuted} />
</View>
<Text style={styles.sectionLabel}>Upcoming events</Text>
<Text style={styles.sectionLabel}>{t('contacts.activity.upcoming_events', 'Upcoming Events')}</Text>
</View>
<View style={styles.sectionBody}>
{upcomingEvents.length === 0 ? (
<Text style={styles.emptyText}>No upcoming events.</Text>
<Text style={styles.emptyText}>{t('contacts.activity.no_events', 'No upcoming events')}</Text>
) : (
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<View
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
/>
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
<Text style={styles.eventTime}>{formatEventTime(event, t)}</Text>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>
{event.title || '(no title)'}
{event.title || t('contacts.activity.no_title', '(No title)')}
</Text>
<Text style={styles.eventDate}>
{formatRelativeDate(event.utcStart || event.start)}
+48 -24
View File
@@ -12,6 +12,7 @@ import { useContactsStore } from '../../stores/contacts-store';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -43,6 +44,7 @@ export default function ContactImportSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const t = useLocaleStore((s) => s.t);
const existing = useContactsStore((s) => s.contacts);
const importContacts = useContactsStore((s) => s.importContacts);
@@ -94,14 +96,14 @@ export default function ContactImportSheet({
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
setError(e instanceof Error ? e.message : t('contacts.import.picker_failed', 'Could not open the file picker'));
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
setError(t('contacts.import.file_too_large', 'File is too large (max 5 MB)'));
return;
}
@@ -109,7 +111,7 @@ export default function ContactImportSheet({
const text = await FileSystem.readAsStringAsync(asset.uri);
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError('No contacts were found in that file.');
setError(t('contacts.import.no_contacts', 'No contacts found in file'));
return;
}
const dupes = detectDuplicates(existing, contacts);
@@ -119,7 +121,7 @@ export default function ContactImportSheet({
setDuplicates(dupes);
setSelected(initial);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
setError(e instanceof Error ? e.message : t('contacts.import.parse_error', 'Failed to parse vCard file'));
}
};
@@ -137,7 +139,10 @@ export default function ContactImportSheet({
const doImport = async () => {
if (!targetBookId) {
Alert.alert('No address book', 'Create an address book before importing contacts.');
Alert.alert(
t('contacts.detail.no_address_book', 'No address book'),
t('contacts.import.no_address_book', 'Create an address book before importing contacts.'),
);
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
@@ -148,7 +153,7 @@ export default function ContactImportSheet({
setResult(outcome);
onImported?.(outcome.imported);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
setError(e instanceof Error ? e.message : t('contacts.import.failed', 'Import failed'));
} finally {
setImporting(false);
}
@@ -162,8 +167,14 @@ export default function ContactImportSheet({
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import contacts</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<Text style={styles.title}>{t('contacts.import.title', 'Import Contacts')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('contacts.import.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
</View>
@@ -174,21 +185,25 @@ export default function ContactImportSheet({
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result.imported} contact{result.imported === 1 ? '' : 's'}.
{t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })}
</Text>
{result.failed > 0 && (
<Text style={[styles.successText, { color: c.error }]}>
{result.failed} contact{result.failed === 1 ? '' : 's'} could not be imported.
{t(
'contacts.import.failed_count',
'{count, plural, one {# contact could not be imported.} other {# contacts could not be imported.}}',
{ count: result.failed },
)}
</Text>
)}
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
<Button variant="outline" size="sm" onPress={onClose}>{t('common.done', 'Done')}</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} accessibilityRole="button">
<Upload size={32} color={c.textMuted} />
<Text style={styles.dropTitle}>Choose a vCard file</Text>
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
<Text style={styles.dropTitle}>{t('contacts.import.choose_file_mobile', 'Choose a vCard file')}</Text>
<Text style={styles.dropHint}>{t('contacts.import.file_types', '.vcf or .vcard files')}</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
@@ -203,24 +218,29 @@ export default function ContactImportSheet({
style={styles.targetRow}
onPress={onChangeTarget}
disabled={!onChangeTarget}
accessibilityRole={onChangeTarget ? 'button' : undefined}
>
<BookUser size={16} color={c.textSecondary} />
<Text style={styles.targetText} numberOfLines={1}>
Import into {targetBookName || 'address book'}
{targetBookName
? t('contacts.import.into_named', 'Import into {name}', { name: targetBookName })
: t('contacts.import.into_address_book', 'Import into address book')}
</Text>
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
{onChangeTarget && <Text style={styles.targetChange}>{t('contacts.import.change_target', 'Change')}</Text>}
</Pressable>
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
{t('contacts.import.found', '{count, plural, one {1 contact found} other {# contacts found}}', { count: parsed.length })}
{' · '}
{t('contacts.import.selected', '{count, plural, one {1 selected} other {# selected}}', { count: selected.size })}
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}>
<Text style={styles.link}>All</Text>
<Pressable onPress={selectAll} hitSlop={6} accessibilityRole="button">
<Text style={styles.link}>{t('contacts.import.select_all', 'Select all')}</Text>
</Pressable>
<Pressable onPress={selectNone} hitSlop={6}>
<Text style={styles.link}>None</Text>
<Pressable onPress={selectNone} hitSlop={6} accessibilityRole="button">
<Text style={styles.link}>{t('contacts.import.deselect_all', 'Deselect all')}</Text>
</Pressable>
</View>
</View>
@@ -236,6 +256,8 @@ export default function ContactImportSheet({
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="checkbox"
accessibilityState={{ checked: isSelected }}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
@@ -248,7 +270,7 @@ export default function ContactImportSheet({
)}
</View>
{isDupe && (
<Text style={styles.dupeBadge}>Duplicate</Text>
<Text style={styles.dupeBadge}>{t('contacts.import.duplicate', 'Duplicate')}</Text>
)}
</Pressable>
);
@@ -263,13 +285,15 @@ export default function ContactImportSheet({
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button variant="outline" onPress={onClose} disabled={importing}>{t('common.cancel', 'Cancel')}</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
{importing
? t('contacts.import.importing', 'Importing...')
: t('contacts.import.import_count', 'Import {count}', { count: selected.size })}
</Button>
</View>
</>
+5 -1
View File
@@ -12,6 +12,7 @@ import {
import SenderAvatar from '../SenderAvatar';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
interface ContactListRowProps {
contact: ContactCard;
@@ -32,7 +33,8 @@ export default function ContactListRow({
}: ContactListRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const name = getContactDisplayName(contact) || 'Unnamed';
const t = useLocaleStore((s) => s.t);
const name = getContactDisplayName(contact) || t('contacts.unnamed', 'Unnamed');
const email = getContactPrimaryEmail(contact);
const org = getPrimaryOrg(contact);
const photoUri = getContactPhotoUri(contact);
@@ -45,6 +47,8 @@ export default function ContactListRow({
<Pressable
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole={selectionMode ? 'checkbox' : 'button'}
accessibilityState={selectionMode ? { checked: !!selected } : undefined}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
+23 -7
View File
@@ -15,6 +15,7 @@ import ContactListRow from './ContactListRow';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
interface Props {
visible: boolean;
@@ -26,9 +27,10 @@ interface Props {
}
export default function ContactPickerSheet({
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
visible, onClose, onSelect, title, excludedIds, multi = true,
}: Props) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
const styles = React.useMemo(() => makeStyles(c), [c]);
const allContacts = useContactsStore((s) => s.contacts);
const sorted = React.useMemo(
@@ -104,18 +106,27 @@ export default function ContactPickerSheet({
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={onClose}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{title}</Text>
<Text style={styles.headerTitle}>{title ?? t('contacts.picker.title', 'Add Contact')}</Text>
{multi && (
<Pressable
onPress={confirm}
disabled={selected.size === 0}
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
hitSlop={8}
accessibilityRole="button"
>
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
<Text style={styles.doneLabel}>
{t('contacts.picker.add_count', '{count, plural, =0 {Add} other {Add (#)}}', { count: selected.size })}
</Text>
</Pressable>
)}
</View>
@@ -125,14 +136,19 @@ export default function ContactPickerSheet({
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
placeholderTextColor={c.textMuted}
value={query}
onChangeText={setQuery}
autoFocus
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery('')} hitSlop={8}>
<Pressable
onPress={() => setQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
>
<X size={16} color={c.textMuted} />
</Pressable>
)}
@@ -152,7 +168,7 @@ export default function ContactPickerSheet({
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>No contacts match</Text>
<Text style={styles.emptyText}>{t('contacts.empty_search', 'No contacts match your search')}</Text>
</View>
}
keyboardShouldPersistTaps="handled"
@@ -17,6 +17,7 @@ import {
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -37,6 +38,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const t = useLocaleStore((s) => s.t);
const selectedCategory = useContactsStore((s) => s.selectedCategory);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const contacts = useContactsStore((s) => s.contacts);
@@ -140,7 +142,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
await renameKeyword(from, to);
setRenaming(null);
} catch (err) {
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.category_rename_failed', 'Failed to rename category'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
} finally {
setRenameBusy(false);
}
@@ -152,7 +154,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
icon={<BookUser size={16} color={c.textSecondary} />}
label={book.name}
count={book.count}
badge={book.isDefault ? 'Default' : undefined}
badge={book.isDefault ? t('contacts.address_books.default', 'Default') : undefined}
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
onPress={() => select({ type: 'addressBook', addressBookId: book.id })}
/>
@@ -167,23 +169,29 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Contacts</Text>
<Text style={styles.headerTitle}>{t('contacts.title', 'Contacts')}</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
<CategoryRow
icon={<Inbox size={16} color={c.primary} />}
label="All Contacts"
label={t('contacts.all_contacts', 'All Contacts')}
count={totalCount}
active={selectedCategory.type === 'all'}
onPress={() => select({ type: 'all' })}
/>
<SectionHeader
label="Address Books"
label={t('contacts.address_books.title', 'My Address Books')}
expanded={expanded.books}
onPress={() => setExpanded((e) => ({ ...e, books: !e.books }))}
/>
@@ -192,7 +200,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{sharedSections.map((section) => (
<React.Fragment key={section.accountId}>
<SectionHeader
label={`Shared from ${section.name}`}
label={t('contacts.address_books.shared_prefix', 'Shared: {name}', { name: section.name })}
icon={<Share2 size={12} color={c.textMuted} />}
expanded={expanded.shared}
onPress={() => setExpanded((e) => ({ ...e, shared: !e.shared }))}
@@ -202,7 +210,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
))}
<SectionHeader
label="Groups"
label={t('contacts.tabs.groups', 'Groups')}
expanded={expanded.groups}
onPress={() => setExpanded((e) => ({ ...e, groups: !e.groups }))}
/>
@@ -210,7 +218,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
<CategoryRow
key={g.id}
icon={<Users size={16} color={c.textSecondary} />}
label={getContactDisplayName(g) || 'Group'}
label={getContactDisplayName(g) || t('contacts.group', 'Group')}
count={memberCountByGroup.get(g.id) ?? 0}
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
onPress={() => select({ type: 'group', groupId: g.id })}
@@ -219,7 +227,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.groups && (
<CategoryRow
icon={<Plus size={16} color={c.primary} />}
label="New group"
label={t('contacts.groups.create', 'New Group')}
count={0}
active={false}
muted
@@ -235,7 +243,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
)}
<SectionHeader
label="Tags"
label={t('sidebar.tags', 'Tags')}
expanded={expanded.tags}
onPress={() => setExpanded((e) => ({ ...e, tags: !e.tags }))}
/>
@@ -252,10 +260,22 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
onSubmitEditing={() => { void commitRename(); }}
placeholderTextColor={c.textMuted}
/>
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.renameBtn}>
<Pressable
onPress={() => { void commitRename(); }}
hitSlop={6}
style={styles.renameBtn}
accessibilityRole="button"
accessibilityLabel={t('common.save', 'Save')}
>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => setRenaming(null)} hitSlop={6} style={styles.renameBtn}>
<Pressable
onPress={() => setRenaming(null)}
hitSlop={6}
style={styles.renameBtn}
accessibilityRole="button"
accessibilityLabel={t('common.cancel', 'Cancel')}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
@@ -277,14 +297,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.tags && (
<CategoryRow
icon={<Tag size={16} color={c.textMuted} />}
label="No category"
label={t('contacts.no_category', 'No Category')}
count={uncategorizedCount}
active={selectedCategory.type === 'uncategorized'}
onPress={() => select({ type: 'uncategorized' })}
/>
)}
{expanded.tags && keywords.length > 0 && (
<Text style={styles.hint}>Long-press a tag to rename it</Text>
<Text style={styles.hint}>{t('contacts.rename_tag_hint_mobile', 'Long-press a tag to rename it')}</Text>
)}
</ScrollView>
</SafeAreaView>
@@ -304,7 +324,12 @@ function SectionHeader({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable style={styles.sectionHeader} onPress={onPress}>
<Pressable
style={styles.sectionHeader}
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ expanded }}
>
{expanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -334,6 +359,8 @@ function CategoryRow({
<Pressable
onPress={onPress}
onLongPress={onLongPress}
accessibilityRole="button"
accessibilityState={{ selected: active }}
style={({ pressed }) => [
styles.row,
active && styles.rowActive,
+16 -5
View File
@@ -8,6 +8,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
interface Props {
visible: boolean;
@@ -18,9 +19,10 @@ interface Props {
}
export default function TagAssignSheet({
visible, onClose, onPick, title = 'Add tag',
visible, onClose, onPick, title,
}: Props) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Subscribe to the stable `contacts` array and derive the keyword list in a
@@ -73,8 +75,14 @@ export default function TagAssignSheet({
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<Text style={styles.title}>{title ?? t('contacts.bulk.add_tag', 'Add tag')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
@@ -83,7 +91,7 @@ export default function TagAssignSheet({
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="New tag name"
placeholder={t('contacts.tag_name_placeholder', 'New tag name')}
placeholderTextColor={c.textMuted}
value={input}
onChangeText={setInput}
@@ -96,6 +104,8 @@ export default function TagAssignSheet({
disabled={!input.trim()}
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('common.add', 'Add')}
>
<Plus size={16} color={c.primaryForeground} />
</Pressable>
@@ -103,13 +113,14 @@ export default function TagAssignSheet({
<ScrollView keyboardShouldPersistTaps="handled">
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags yet. Type one above.</Text>
<Text style={styles.empty}>{t('contacts.no_tags_hint', 'No tags yet. Type one above.')}</Text>
) : (
keywords.map((kw) => (
<Pressable
key={kw.keyword}
onPress={() => onPick(kw.keyword)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<Tag size={16} color={c.textSecondary} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
+162 -73
View File
@@ -31,6 +31,7 @@ import { ContactActivity } from '../components/contacts/ContactActivity';
import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
@@ -42,6 +43,55 @@ function formatTimestamp(s: string | undefined): string {
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
// JSContact stores contexts, phone features and personal-info kinds as
// English keys; show the known ones in the UI language.
function contextLabel(key: string, t: TranslateFn): string {
switch (key) {
case 'work': return t('contacts.form.context_work', 'Work');
case 'private': return t('contacts.form.context_private', 'Private');
default: return key;
}
}
function phoneFeatureLabel(key: string, t: TranslateFn): string {
switch (key) {
case 'voice': return t('contacts.form.phone_voice', 'Voice');
case 'cell': return t('contacts.form.phone_cell', 'Mobile');
case 'fax': return t('contacts.form.phone_fax', 'Fax');
case 'pager': return t('contacts.form.phone_pager', 'Pager');
case 'video': return t('contacts.form.phone_video', 'Video');
case 'text': return t('contacts.form.phone_text', 'Text');
default: return key;
}
}
function personalInfoLabel(kind: string, level: string | undefined, t: TranslateFn): string {
const kindLabel =
kind === 'hobby' ? t('contacts.detail.personal_hobby', 'Hobby')
: kind === 'expertise' ? t('contacts.detail.personal_expertise', 'Expertise')
: kind === 'interest' ? t('contacts.detail.personal_interest', 'Interest')
: kind === 'other' ? t('contacts.detail.personal_other', 'Other')
: kind;
if (!level) return kindLabel;
const levelLabel =
level === 'low' ? t('contacts.form.level_low', 'Low')
: level === 'medium' ? t('contacts.form.level_medium', 'Medium')
: level === 'high' ? t('contacts.form.level_high', 'High')
: level;
return `${kindLabel} · ${levelLabel}`;
}
function genderLabel(value: string, t: TranslateFn): string {
switch (value) {
case 'masculine': return t('contacts.detail.gender_masculine', 'Male');
case 'feminine': return t('contacts.detail.gender_feminine', 'Female');
case 'other': return t('contacts.detail.gender_other', 'Other');
case 'none': return t('contacts.detail.gender_none', 'Not applicable');
case 'unknown': return t('contacts.detail.gender_unknown', 'Unknown');
default: return value;
}
}
function buildAddressLines(a: {
components?: Array<{ kind: string; value: string }>;
full?: string;
@@ -79,6 +129,7 @@ export default function ContactDetailScreen() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t);
const { contactId } = route.params;
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
@@ -99,19 +150,27 @@ export default function ContactDetailScreen() {
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Contact</Text>
<Text style={styles.headerTitle}>{t('contacts.contact', 'Contact')}</Text>
</View>
<View style={styles.missing}>
<Text style={styles.missingText}>Contact not found</Text>
<Text style={styles.missingText}>{t('contacts.detail.not_found', 'Contact not found')}</Text>
</View>
</SafeAreaView>
);
}
const name = getContactDisplayName(contact) || 'Unnamed';
const name = getContactDisplayName(contact) || t('contacts.unnamed', 'Unnamed');
const ctx = (contexts?: Record<string, boolean>): string[] =>
getActiveContexts(contexts).map((k) => contextLabel(k, t));
const nickname = contact.nicknames
? Object.values(contact.nicknames).map((n) => n.name).filter(Boolean).join(', ')
: getPrimaryNickname(contact);
@@ -168,20 +227,20 @@ export default function ContactDetailScreen() {
});
};
const openTel = (num: string) => {
Linking.openURL(`tel:${num}`).catch(() => Alert.alert('Cannot open dialer'));
Linking.openURL(`tel:${num}`).catch(() => Alert.alert(t('contacts.detail.open_dialer_failed', 'Cannot open dialer')));
};
const openSms = (num: string) => {
Linking.openURL(`sms:${num}`).catch(() => Alert.alert('Cannot open messaging app'));
Linking.openURL(`sms:${num}`).catch(() => Alert.alert(t('contacts.detail.open_sms_failed', 'Cannot open messaging app')));
};
const openMap = (query: string) => {
const encoded = encodeURIComponent(query);
Linking.openURL(`https://maps.google.com/?q=${encoded}`).catch(() => Alert.alert('Cannot open maps'));
Linking.openURL(`https://maps.google.com/?q=${encoded}`).catch(() => Alert.alert(t('contacts.detail.open_maps_failed', 'Cannot open maps')));
};
const openUrl = (uri: string) => {
// Contact data is server-supplied: only hand http(s)/mailto/tel/sms/geo
// schemes to the OS, never intent:// / file:// / third-party deep links.
void openExternalUrl(uri).then((opened) => {
if (!opened) Alert.alert('Cannot open link');
if (!opened) Alert.alert(t('contacts.detail.open_link_failed', 'Cannot open link'));
});
};
const shareValue = (value: string) => {
@@ -194,7 +253,7 @@ export default function ContactDetailScreen() {
await deleteContact(contact.id);
navigation.goBack();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.toast.error_delete', 'Failed to delete contact'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -208,7 +267,7 @@ export default function ContactDetailScreen() {
await Sharing.shareAsync(path, {
mimeType: 'text/vcard',
UTI: 'public.vcard',
dialogTitle: `Share ${name}`,
dialogTitle: t('contacts.detail.share_title', 'Share {name}', { name }),
});
} else {
await Share.share({ message: vcard });
@@ -221,7 +280,10 @@ export default function ContactDetailScreen() {
const doDuplicate = async () => {
const targetBookId = bookIds[0] || addressBooks[0]?.id;
if (!targetBookId) {
Alert.alert('No address book', 'Cannot duplicate without an address book.');
Alert.alert(
t('contacts.detail.no_address_book', 'No address book'),
t('contacts.detail.duplicate_no_book', 'Cannot duplicate without an address book.'),
);
return;
}
// Drop the UID too: the copy gets a fresh one on create, otherwise group
@@ -239,8 +301,7 @@ export default function ContactDetailScreen() {
...rest
} = contact;
const baseName =
(rest.name?.full ? `${rest.name.full} (Copy)` : null) ??
`${name} (Copy)`;
t('contacts.detail.copy_name', '{name} (Copy)', { name: rest.name?.full || name });
const draft: Partial<ContactCard> = {
...rest,
name: rest.name ? { ...rest.name, full: baseName } : { full: baseName },
@@ -249,7 +310,7 @@ export default function ContactDetailScreen() {
const created = await createContact(draft, targetBookId);
navigation.replace('ContactDetail', { contactId: created.id });
} catch (err) {
Alert.alert('Duplicate failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.detail.duplicate_failed', 'Duplicate failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -258,35 +319,35 @@ export default function ContactDetailScreen() {
try {
await moveContactsToAddressBook([contact.id], bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.address_books.move_failed', 'Failed to move contact'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
const moreItems = [
!isGroup(contact) && {
icon: <Copy size={16} color={c.text} />,
label: 'Duplicate',
label: t('contacts.context_menu.duplicate', 'Duplicate'),
onPress: () => { void doDuplicate(); },
},
!isGroup(contact) && {
icon: <Users size={16} color={c.text} />,
label: 'Add to group',
label: t('contacts.context_menu.add_to_group', 'Add to group'),
onPress: () => setGroupPickerOpen(true),
},
addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book',
label: t('contacts.bulk.move_to_address_book', 'Move to address book'),
onPress: () => setMoveOpen(true),
},
{
icon: <Share2 size={16} color={c.text} />,
label: 'Export vCard',
label: t('contacts.context_menu.export_vcard', 'Export as vCard'),
onPress: () => { void doShare(); },
},
{ separator: true },
{
icon: <Trash2 size={16} color={c.error} />,
label: 'Delete',
label: t('contacts.context_menu.delete', 'Delete'),
onPress: () => setConfirmDelete(true),
destructive: true,
},
@@ -295,17 +356,25 @@ export default function ContactDetailScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{isGroup(contact) ? 'Group' : 'Contact'}
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
</Text>
<View style={styles.headerActions}>
<Pressable
onPress={() => navigation.navigate('ContactForm', { contactId: contact.id })}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.context_menu.edit', 'Edit')}
>
<Pencil size={18} color={c.text} />
</Pressable>
@@ -313,6 +382,8 @@ export default function ContactDetailScreen() {
onPress={() => setMoreOpen(true)}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.detail.more_actions', 'More actions')}
>
<MoreHorizontal size={20} color={c.text} />
</Pressable>
@@ -332,33 +403,35 @@ export default function ContactDetailScreen() {
<Text style={styles.heroSubtitle}>{subtitleParts.join(' · ')}</Text>
)}
{isGroup(contact) && groupMembersCount > 0 && (
<Text style={styles.heroSubtitle}>{groupMembersCount} member{groupMembersCount === 1 ? '' : 's'}</Text>
<Text style={styles.heroSubtitle}>
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: groupMembersCount })}
</Text>
)}
</View>
<View style={styles.quickActions}>
{!!email && (
<QuickAction icon={<Mail size={18} color={c.primary} />} label="Email" onPress={() => openMail(email)} />
<QuickAction icon={<Mail size={18} color={c.primary} />} label={t('contacts.detail.email_default_label', 'Email')} onPress={() => openMail(email)} />
)}
{!!phone && (
<QuickAction icon={<Phone size={18} color={c.primary} />} label="Call" onPress={() => openTel(phone)} />
<QuickAction icon={<Phone size={18} color={c.primary} />} label={t('contacts.context_menu.call', 'Call')} onPress={() => openTel(phone)} />
)}
{!!phone && (
<QuickAction icon={<MessageSquare size={18} color={c.primary} />} label="SMS" onPress={() => openSms(phone)} />
<QuickAction icon={<MessageSquare size={18} color={c.primary} />} label={t('contacts.detail.sms', 'SMS')} onPress={() => openSms(phone)} />
)}
<QuickAction
icon={<Share2 size={18} color={c.primary} />}
label="Share"
label={t('contacts.detail.share', 'Share')}
onPress={() => { void doShare(); }}
/>
</View>
<View style={styles.sections}>
{emails.length > 0 && (
<Section icon={<Mail size={16} color={c.textMuted} />} label="Email">
<Section icon={<Mail size={16} color={c.textMuted} />} label={t('contacts.detail.email_default_label', 'Email')}>
{emails.map((e) => {
const ctxLabel =
e.label || getActiveContexts(e.contexts).join(', ') || undefined;
e.label || ctx(e.contexts).join(', ') || undefined;
return (
<DetailRow key={e.id} label={ctxLabel}>
<Pressable
@@ -374,12 +447,12 @@ export default function ContactDetailScreen() {
)}
{phones.length > 0 && (
<Section icon={<Phone size={16} color={c.textMuted} />} label="Phone">
<Section icon={<Phone size={16} color={c.textMuted} />} label={t('contacts.detail.phone_default_label', 'Phone')}>
{phones.map((p) => {
const features = getPhoneFeatures(p.features);
const features = getPhoneFeatures(p.features).map((f) => phoneFeatureLabel(f, t));
const labelParts = [
p.label,
...getActiveContexts(p.contexts),
...ctx(p.contexts),
...features,
].filter(Boolean) as string[];
return (
@@ -392,7 +465,13 @@ export default function ContactDetailScreen() {
>
<Text style={styles.linkText}>{p.number}</Text>
</Pressable>
<Pressable onPress={() => openSms(p.number)} hitSlop={6} style={styles.smallActionBtn}>
<Pressable
onPress={() => openSms(p.number)}
hitSlop={6}
style={styles.smallActionBtn}
accessibilityRole="button"
accessibilityLabel={t('contacts.detail.sms', 'SMS')}
>
<MessageSquare size={14} color={c.textMuted} />
</Pressable>
</View>
@@ -403,11 +482,11 @@ export default function ContactDetailScreen() {
)}
{addresses.length > 0 && (
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Address">
<Section icon={<MapPin size={16} color={c.textMuted} />} label={t('contacts.detail.address_default_label', 'Address')}>
{addresses.map((a) => {
const lines = buildAddressLines(a);
const formatted = formatAddress(a);
const ctxLabel = getActiveContexts(a.contexts).join(', ') || a.label;
const ctxLabel = ctx(a.contexts).join(', ') || a.label;
return (
<DetailRow key={a.id} label={ctxLabel}>
<Pressable
@@ -418,7 +497,7 @@ export default function ContactDetailScreen() {
<Text key={idx} style={styles.value}>{line}</Text>
))}
{!!a.timeZone && (
<Text style={styles.subValue}>Timezone: {a.timeZone}</Text>
<Text style={styles.subValue}>{t('contacts.detail.timezone_value', 'Timezone: {zone}', { zone: a.timeZone })}</Text>
)}
</Pressable>
</DetailRow>
@@ -428,9 +507,9 @@ export default function ContactDetailScreen() {
)}
{(orgs.length > 0 || titles.length > 0) && (
<Section icon={<Building size={16} color={c.textMuted} />} label="Work">
<Section icon={<Building size={16} color={c.textMuted} />} label={t('contacts.detail.section_work', 'Work')}>
{orgs.map((o, i) => (
<DetailRow key={`org-${i}`} label="Organization">
<DetailRow key={`org-${i}`} label={t('contacts.detail.organization_label', 'Organization')}>
<Text style={styles.value}>{o.name}</Text>
{!!(o.units && o.units.length) && (
<Text style={styles.subValue}>{o.units.map((u) => u.name).join(', ')}</Text>
@@ -438,12 +517,12 @@ export default function ContactDetailScreen() {
</DetailRow>
))}
{jobTitles.map((tl, i) => (
<DetailRow key={`title-${i}`} label="Title">
<DetailRow key={`title-${i}`} label={t('contacts.detail.title_label', 'Title')}>
<Text style={styles.value}>{tl.name}</Text>
</DetailRow>
))}
{roles.map((r, i) => (
<DetailRow key={`role-${i}`} label="Role">
<DetailRow key={`role-${i}`} label={t('contacts.detail.role_label', 'Role')}>
<Text style={styles.value}>{r.name}</Text>
</DetailRow>
))}
@@ -451,20 +530,20 @@ export default function ContactDetailScreen() {
)}
{(anniversaries.length > 0 || hasGender || preferredLanguages.length > 0 || personalInfo.length > 0) && (
<Section icon={<Heart size={16} color={c.textMuted} />} label="Personal">
<Section icon={<Heart size={16} color={c.textMuted} />} label={t('contacts.detail.section_personal', 'Personal')}>
{anniversaries.map((a, i) => {
const years = getCompletedYears(a.date);
const suffix =
years !== null
? a.kind === 'birth'
? ` · age ${years}`
: ` · ${years} year${years === 1 ? '' : 's'} ago`
? ` · ${t('contacts.detail.age_years', '{count, plural, one {1 year old} other {# years old}}', { count: years })}`
: ` · ${t('contacts.detail.years_since', '{count, plural, one {1 year} other {# years}}', { count: years })}`
: '';
const kindLabel =
a.kind === 'birth' ? 'Birthday'
: a.kind === 'wedding' ? 'Anniversary'
: a.kind === 'death' ? 'Memorial'
: 'Other';
a.kind === 'birth' ? t('contacts.detail.anniversary_birth', 'Birthday')
: a.kind === 'wedding' ? t('contacts.detail.anniversary_wedding', 'Anniversary')
: a.kind === 'death' ? t('contacts.form.anniversary_death', 'Memorial')
: t('contacts.detail.anniversary_other', 'Other');
return (
<DetailRow key={`an-${i}`} label={kindLabel} icon={<Cake size={14} color={c.textMuted} />}>
<Text style={styles.value}>{formatPartialDate(a.date)}{suffix}</Text>
@@ -472,16 +551,19 @@ export default function ContactDetailScreen() {
);
})}
{hasGender && (
<DetailRow label="Gender" icon={<UserCircle size={14} color={c.textMuted} />}>
<DetailRow label={t('contacts.detail.gender', 'Gender')} icon={<UserCircle size={14} color={c.textMuted} />}>
<Text style={styles.value}>
{[contact.speakToAs?.grammaticalGender, firstPronoun].filter(Boolean).join(' · ')}
{[
contact.speakToAs?.grammaticalGender && genderLabel(contact.speakToAs.grammaticalGender, t),
firstPronoun,
].filter(Boolean).join(' · ')}
</Text>
</DetailRow>
)}
{preferredLanguages.map((lang, i) => (
<DetailRow
key={`lg-${i}`}
label={getActiveContexts(lang.contexts).join(', ') || 'Language'}
label={ctx(lang.contexts).join(', ') || t('contacts.detail.language_label', 'Language')}
icon={<Languages size={14} color={c.textMuted} />}
>
<Text style={styles.value}>{lang.language}</Text>
@@ -490,7 +572,7 @@ export default function ContactDetailScreen() {
{personalInfo.map((pi, i) => (
<DetailRow
key={`pi-${i}`}
label={`${pi.kind}${pi.level ? ` · ${pi.level}` : ''}`}
label={personalInfoLabel(pi.kind, pi.level, t)}
>
<Text style={styles.value}>{pi.value}</Text>
</DetailRow>
@@ -499,9 +581,9 @@ export default function ContactDetailScreen() {
)}
{onlineServices.length > 0 && (
<Section icon={<Globe size={16} color={c.textMuted} />} label="Online">
<Section icon={<Globe size={16} color={c.textMuted} />} label={t('contacts.detail.online_service_default_label', 'Online')}>
{onlineServices.map((s, i) => {
const labelParts = [s.service, ...getActiveContexts(s.contexts)].filter(Boolean) as string[];
const labelParts = [s.service, ...ctx(s.contexts)].filter(Boolean) as string[];
const isHttp = typeof s.uri === 'string' && /^https?:/i.test(s.uri);
return (
<DetailRow key={`os-${i}`} label={labelParts.join(' · ') || undefined}>
@@ -518,23 +600,23 @@ export default function ContactDetailScreen() {
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={<CalendarIcon size={16} color={c.textMuted} />} label="Calendar">
<Section icon={<CalendarIcon size={16} color={c.textMuted} />} label={t('contacts.detail.calendar', 'Calendar')}>
{!!contact.calendarUri && (
<DetailRow label="Calendar">
<DetailRow label={t('contacts.detail.calendar_uri', 'Calendar URL')}>
<Pressable onPress={() => openUrl(contact.calendarUri!)}>
<Text style={[styles.value, styles.linkText]} numberOfLines={2}>{contact.calendarUri}</Text>
</Pressable>
</DetailRow>
)}
{!!contact.schedulingUri && (
<DetailRow label="Scheduling">
<DetailRow label={t('contacts.detail.scheduling_uri', 'Scheduling URL')}>
<Pressable onPress={() => openUrl(contact.schedulingUri!)}>
<Text style={[styles.value, styles.linkText]} numberOfLines={2}>{contact.schedulingUri}</Text>
</Pressable>
</DetailRow>
)}
{!!contact.freeBusyUri && (
<DetailRow label="Free/Busy">
<DetailRow label={t('contacts.detail.freebusy_uri', 'Free/Busy URL')}>
<Pressable onPress={() => openUrl(contact.freeBusyUri!)}>
<Text style={[styles.value, styles.linkText]} numberOfLines={2}>{contact.freeBusyUri}</Text>
</Pressable>
@@ -544,9 +626,9 @@ export default function ContactDetailScreen() {
)}
{cryptoKeys.length > 0 && (
<Section icon={<KeyRound size={16} color={c.textMuted} />} label="Crypto Keys">
<Section icon={<KeyRound size={16} color={c.textMuted} />} label={t('contacts.detail.crypto_keys', 'Crypto Keys')}>
{cryptoKeys.map((key, i) => (
<DetailRow key={`ck-${i}`} label={getActiveContexts(key.contexts).join(', ') || key.mediaType}>
<DetailRow key={`ck-${i}`} label={ctx(key.contexts).join(', ') || key.mediaType}>
<Text style={styles.value} numberOfLines={3}>
{typeof key.uri === 'string'
? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? '…' : ''}`
@@ -558,7 +640,7 @@ export default function ContactDetailScreen() {
)}
{keywords.length > 0 && (
<Section icon={<Tag size={16} color={c.textMuted} />} label="Categories">
<Section icon={<Tag size={16} color={c.textMuted} />} label={t('contacts.detail.categories', 'Categories')}>
<View style={styles.chipRow}>
{keywords.map((kw) => (
<View key={kw} style={styles.tagChip}>
@@ -570,7 +652,7 @@ export default function ContactDetailScreen() {
)}
{relatedTo.length > 0 && (
<Section icon={<Users size={16} color={c.textMuted} />} label="Related">
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.detail.related_default_label', 'Related')}>
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation
? Object.keys(rel.relation).find((k) => rel.relation![k])
@@ -585,7 +667,7 @@ export default function ContactDetailScreen() {
)}
{memberContacts.length > 0 && (
<Section icon={<Users size={16} color={c.textMuted} />} label={`Members (${memberContacts.length})`}>
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.groups.members_with_count', 'Members ({count})', { count: memberContacts.length })}>
{memberContacts.map((m) => (
<Pressable
key={m.id}
@@ -609,7 +691,7 @@ export default function ContactDetailScreen() {
)}
{notes.length > 0 && (
<Section icon={<FileText size={16} color={c.textMuted} />} label="Notes">
<Section icon={<FileText size={16} color={c.textMuted} />} label={t('contacts.detail.notes', 'Notes')}>
{notes.map((n, i) => (
<Text key={i} style={styles.noteText}>{n.note}</Text>
))}
@@ -617,7 +699,7 @@ export default function ContactDetailScreen() {
)}
{bookNames.length > 0 && (
<Section icon={<BookUser size={16} color={c.textMuted} />} label="Address Book">
<Section icon={<BookUser size={16} color={c.textMuted} />} label={t('contacts.address_books.address_book', 'Address Book')}>
{bookNames.map((n, i) => (
<Text key={i} style={styles.value}>{n}</Text>
))}
@@ -630,9 +712,9 @@ export default function ContactDetailScreen() {
<View style={styles.metaRow}>
<Clock size={12} color={c.textMuted} />
<Text style={styles.metaText}>
{contact.created && `Created ${formatTimestamp(contact.created)}`}
{contact.created && t('contacts.detail.created_on', 'Created {date}', { date: formatTimestamp(contact.created) })}
{contact.created && contact.updated && ' · '}
{contact.updated && `Updated ${formatTimestamp(contact.updated)}`}
{contact.updated && t('contacts.detail.updated_on', 'Updated {date}', { date: formatTimestamp(contact.updated) })}
</Text>
</View>
)}
@@ -641,10 +723,12 @@ export default function ContactDetailScreen() {
<Dialog
visible={confirmDelete}
title={isGroup(contact) ? 'Delete group' : 'Delete contact'}
message={`Are you sure you want to delete "${name}"? This cannot be undone.`}
title={isGroup(contact)
? t('contacts.groups.delete_confirm_title', 'Delete group')
: t('contacts.delete_confirm_title', 'Delete contact')}
message={t('files.delete_confirm_message', 'Are you sure you want to delete "{name}"? This cannot be undone.', { name })}
variant="destructive"
confirmText="Delete"
confirmText={t('contacts.context_menu.delete', 'Delete')}
onConfirm={doDelete}
onCancel={() => setConfirmDelete(false)}
/>
@@ -661,17 +745,17 @@ export default function ContactDetailScreen() {
items={[
...groups.map((g): MoreItem => ({
icon: <Users size={16} color={c.text} />,
label: getContactDisplayName(g) || 'Group',
label: getContactDisplayName(g) || t('contacts.group', 'Group'),
onPress: () => {
addContactsToGroup(g.id, [contact.id]).catch((err) => {
Alert.alert('Add to group failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.detail.add_to_group_failed', 'Add to group failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
});
},
})),
...(groups.length > 0 ? [{ separator: true } as MoreItem] : []),
{
icon: <Plus size={16} color={c.text} />,
label: 'New group…',
label: t('contacts.groups.create_ellipsis', 'New group…'),
onPress: () => navigation.navigate('ContactForm', { asGroup: true, memberIds: [contact.id] }),
},
]}
@@ -736,6 +820,7 @@ function MoreActionsSheet({
<Pressable
key={i}
onPress={() => { it.onPress(); onClose(); }}
accessibilityRole="button"
style={({ pressed }) => [styles.sheetItem, pressed && styles.sheetItemPressed]}
>
{it.icon}
@@ -803,7 +888,11 @@ function QuickAction({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable style={({ pressed }) => [styles.quickBtn, pressed && styles.quickBtnPressed]} onPress={onPress}>
<Pressable
style={({ pressed }) => [styles.quickBtn, pressed && styles.quickBtnPressed]}
onPress={onPress}
accessibilityRole="button"
>
<View style={styles.quickIcon}>{icon}</View>
<Text style={styles.quickLabel}>{label}</Text>
</Pressable>
+241 -165
View File
@@ -30,6 +30,7 @@ import Dialog from '../components/Dialog';
import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
@@ -482,58 +483,57 @@ function formToPatch(
return patch as Partial<ContactCard>;
}
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' },
];
// Same vocabulary as the webmail form and the vCard SEX mapping
// (M/F/O/N/U), so a card renders the same value in both apps.
const GENDER_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: 'Unspecified' },
{ value: 'masculine', label: 'Masculine' },
{ value: 'feminine', label: 'Feminine' },
{ value: 'other', label: 'Other' },
{ value: 'none', label: 'None' },
{ value: 'unknown', label: 'Unknown' },
];
const KIND_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'person', label: 'Person' },
{ value: 'org', label: 'Organization' },
];
type Option = { value: string; label: string };
function formOptions(t: TranslateFn) {
const contexts: Option[] = [
{ value: '', label: t('contacts.form.context_none', 'None') },
{ value: 'work', label: t('contacts.form.context_work', 'Work') },
{ value: 'private', label: t('contacts.form.context_private', 'Private') },
];
const phoneFeatures: Option[] = [
{ value: '', label: t('contacts.form.phone', 'Phone') },
{ value: 'voice', label: t('contacts.form.phone_voice', 'Voice') },
{ value: 'cell', label: t('contacts.form.phone_cell', 'Mobile') },
{ value: 'fax', label: t('contacts.form.phone_fax', 'Fax') },
{ value: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
{ value: 'text', label: t('contacts.form.phone_text', 'Text') },
];
const anniversaryKinds: Option[] = [
{ value: 'birth', label: t('contacts.form.anniversary_birth', 'Birthday') },
{ value: 'wedding', label: t('contacts.form.anniversary_wedding', 'Anniversary') },
{ value: 'death', label: t('contacts.form.anniversary_death', 'Memorial') },
{ value: 'other', label: t('contacts.form.anniversary_other', 'Other') },
];
const personalInfoKinds: Option[] = [
{ value: 'hobby', label: t('contacts.form.personal_hobby', 'Hobby') },
{ value: 'expertise', label: t('contacts.form.personal_expertise', 'Expertise') },
{ value: 'interest', label: t('contacts.form.personal_interest', 'Interest') },
{ value: 'other', label: t('contacts.form.personal_other', 'Other') },
];
const personalInfoLevels: Option[] = [
{ value: '', label: '–' },
{ value: 'low', label: t('contacts.form.level_low', 'Low') },
{ value: 'medium', label: t('contacts.form.level_medium', 'Medium') },
{ value: 'high', label: t('contacts.form.level_high', 'High') },
];
// Same vocabulary as the webmail form and the vCard SEX mapping
// (M/F/O/N/U), so a card renders the same value in both apps.
const gender: Option[] = [
{ value: '', label: t('contacts.form.gender_unspecified', 'Unspecified') },
{ value: 'masculine', label: t('contacts.form.gender_male', 'Male') },
{ value: 'feminine', label: t('contacts.form.gender_female', 'Female') },
{ value: 'other', label: t('contacts.form.gender_other', 'Other') },
{ value: 'none', label: t('contacts.form.gender_none', 'Not applicable') },
{ value: 'unknown', label: t('contacts.form.gender_unknown', 'Unknown') },
];
const kind: Option[] = [
{ value: 'person', label: t('contacts.form.type_person', 'Person') },
{ value: 'org', label: t('contacts.form.type_organization', 'Organization') },
];
return { contexts, phoneFeatures, anniversaryKinds, personalInfoKinds, personalInfoLevels, gender, kind };
}
const MAX_PHOTO_DIM = 512;
const PHOTO_QUALITY = 0.85;
@@ -588,6 +588,8 @@ function Pills({
key={o.value}
onPress={() => onChange(o.value)}
style={[styles.pill, active && styles.pillActive]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Text style={[styles.pillText, active && styles.pillTextActive]}>{o.label}</Text>
</Pressable>
@@ -618,6 +620,8 @@ function Section({
onPress={() => collapsible && setOpen((o) => !o)}
style={styles.sectionHeader}
disabled={!collapsible}
accessibilityRole={collapsible ? 'button' : 'header'}
accessibilityState={collapsible ? { expanded: isOpen } : undefined}
>
<View style={styles.sectionIcon}>{icon}</View>
<Text style={styles.sectionLabel}>{label}</Text>
@@ -651,10 +655,17 @@ function RemovableRow({
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={styles.removableRow}>
<View style={{ flex: 1 }}>{children}</View>
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
<Pressable
onPress={onRemove}
hitSlop={8}
style={styles.removeBtn}
accessibilityRole="button"
accessibilityLabel={t('common.remove', 'Remove')}
>
<XIcon size={14} color={c.textMuted} />
</Pressable>
</View>
@@ -665,7 +676,11 @@ 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]}>
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.addBtn, pressed && styles.addBtnPressed]}
accessibilityRole="button"
>
<Plus size={14} color={c.primary} />
<Text style={styles.addBtnLabel}>{label}</Text>
</Pressable>
@@ -677,6 +692,8 @@ export default function ContactFormScreen() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t);
const opts = React.useMemo(() => formOptions(t), [t]);
const { contactId, addressBookId: initialBook, asGroup, prefill, memberIds: initialMemberIds } = route.params || {};
const isEdit = !!contactId;
@@ -753,12 +770,18 @@ export default function ContactFormScreen() {
const orgName = form.orgs[0]?.name.trim() || '';
if (asGroup) {
if (!form.given.trim()) {
Alert.alert('Missing info', 'Give the group a name.');
Alert.alert(
t('contacts.form.missing_info', 'Missing info'),
t('contacts.groups.name_required', 'Group name is required'),
);
return;
}
} else if (form.isOrg) {
if (!orgName) {
Alert.alert('Missing info', 'Add the organization name.');
Alert.alert(
t('contacts.form.missing_info', 'Missing info'),
t('contacts.form.org_name_required', 'Add the organization name.'),
);
return;
}
} else {
@@ -766,18 +789,27 @@ export default function ContactFormScreen() {
const hasEmail = form.emails.some((e) => e.address.trim());
// An organization name identifies the card just as well as a personal name.
if (!hasName && !hasEmail && !orgName) {
Alert.alert('Missing info', 'Add a name or at least one email.');
Alert.alert(
t('contacts.form.missing_info', 'Missing info'),
t('contacts.form.name_or_email_required', '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.');
Alert.alert(
t('contacts.form.missing_address_book', 'Missing address book'),
t('contacts.form.address_book_required', '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.`);
Alert.alert(
t('contacts.form.invalid_email_title', 'Invalid email'),
t('contacts.form.invalid_email_message', '"{address}" is not a valid email address.', { address: e.address }),
);
return;
}
}
@@ -785,7 +817,14 @@ export default function ContactFormScreen() {
// free text, and a rejected date used to take the whole card with it.
for (const a of form.anniversaries) {
if (a.date.trim() && !stringToPartialDate(a.date)) {
Alert.alert('Invalid date', `"${a.date}" is not a date. Use YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD.`);
Alert.alert(
t('contacts.form.invalid_date_title', 'Invalid date'),
t(
'contacts.form.invalid_date_message',
'"{date}" is not a date. Use YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD.',
{ date: a.date },
),
);
return;
}
}
@@ -801,15 +840,23 @@ export default function ContactFormScreen() {
else navigation.replace('ContactDetail', { contactId: created.id });
}
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(
asGroup
? t('contacts.groups.save_failed', 'Failed to save group')
: t('contacts.form.save_failed', 'Failed to save contact'),
err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'),
);
} finally {
setSaving(false);
}
};
const existingName = existing ? getContactDisplayName(existing) : '';
const headerTitle = isEdit
? `Edit ${existing ? getContactDisplayName(existing) : asGroup ? 'Group' : 'Contact'}`
: asGroup ? 'New Group' : 'New Contact';
? existingName
? t('contacts.form.edit_named', 'Edit {name}', { name: existingName })
: asGroup ? t('contacts.groups.edit', 'Edit Group') : t('contacts.form.edit_title', 'Edit Contact')
: asGroup ? t('contacts.groups.create', 'New Group') : t('contacts.form.create_title', 'New Contact');
const previewName = form.isOrg
? (form.orgs[0]?.name || '').trim()
@@ -818,7 +865,10 @@ export default function ContactFormScreen() {
const pickPhoto = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Photo access needed', 'Grant photo library permission to pick a contact photo.');
Alert.alert(
t('contacts.form.photo_permission_title', 'Photo access needed'),
t('contacts.form.photo_permission_message', 'Grant photo library permission to pick a contact photo.'),
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -842,7 +892,7 @@ export default function ContactFormScreen() {
const memberIdSet = React.useMemo(() => new Set(form.members), [form.members]);
const addressBookSection = addressBooks.length > 1 && (
<Section icon={<Book size={16} color={c.textMuted} />} label="Address Book">
<Section icon={<Book size={16} color={c.textMuted} />} label={t('contacts.address_books.address_book', 'Address Book')}>
<View style={styles.pillRow}>
{addressBooks.filter((b) => b.myRights?.mayWrite !== false || b.id === form.addressBookId).map((book) => (
<Pressable
@@ -862,7 +912,7 @@ export default function ContactFormScreen() {
const notesSection = (
<Section
icon={<FileText size={16} color={c.textMuted} />}
label="Notes"
label={t('contacts.form.note', 'Notes')}
collapsible
defaultOpen={form.notes.length > 0}
>
@@ -873,7 +923,7 @@ export default function ContactFormScreen() {
>
<TextInput
style={[styles.input, styles.multiline]}
placeholder="Notes"
placeholder={t('contacts.form.note_placeholder', 'Add a note...')}
placeholderTextColor={c.textMuted}
multiline
value={n.note}
@@ -886,7 +936,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add note"
label={t('contacts.form.add_note', 'Add note')}
onPress={() => updateForm('notes', [...form.notes, { note: '' }])}
/>
</Section>
@@ -895,7 +945,7 @@ export default function ContactFormScreen() {
const categoriesSection = (
<Section
icon={<Tag size={16} color={c.textMuted} />}
label="Categories"
label={t('contacts.form.categories', 'Categories')}
collapsible
defaultOpen={form.keywords.length > 0}
>
@@ -915,7 +965,7 @@ export default function ContactFormScreen() {
)}
<TextInput
style={styles.input}
placeholder="Add tag and press Enter"
placeholder={t('contacts.form.categories_placeholder_mobile', 'Add tag and press Enter')}
placeholderTextColor={c.textMuted}
value={keywordInput}
onChangeText={setKeywordInput}
@@ -949,17 +999,31 @@ export default function ContactFormScreen() {
const header = (
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={handleBack}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<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}>
<Pressable
onPress={handleSave}
disabled={saving}
style={styles.saveBtn}
hitSlop={8}
accessibilityRole="button"
>
{saving ? (
<Text style={styles.saveLabel}>Saving…</Text>
<Text style={styles.saveLabel}>
{isEdit ? t('contacts.form.updating', 'Updating...') : t('contacts.form.creating', 'Creating...')}
</Text>
) : (
<>
<Check size={16} color={c.primaryForeground} />
<Text style={styles.saveLabel}>Save</Text>
<Text style={styles.saveLabel}>{t('contacts.form.save', 'Save')}</Text>
</>
)}
</Pressable>
@@ -969,10 +1033,10 @@ export default function ContactFormScreen() {
const discardDialog = (
<Dialog
visible={confirmDiscard}
title="Discard changes?"
message="You have unsaved changes. Discard them?"
title={t('settings.discard_changes', 'Discard unsaved changes?')}
message={t('settings.unsaved_changes', 'You have unsaved changes')}
variant="destructive"
confirmText="Discard"
confirmText={t('settings.discard', 'Discard')}
onConfirm={() => {
setConfirmDiscard(false);
navigation.goBack();
@@ -993,11 +1057,11 @@ export default function ContactFormScreen() {
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{addressBookSection}
<Section icon={<Users size={16} color={c.textMuted} />} label="Group">
<Field label="Group name">
<Section icon={<Users size={16} color={c.textMuted} />} label={t('contacts.group', 'Group')}>
<Field label={t('contacts.groups.name_label', 'Group Name')}>
<TextInput
style={styles.input}
placeholder="Sales team"
placeholder={t('contacts.groups.name_placeholder', 'e.g., Team, Family')}
placeholderTextColor={c.textMuted}
value={form.given}
onChangeText={(v) => updateForm('given', v)}
@@ -1006,7 +1070,7 @@ export default function ContactFormScreen() {
</Field>
</Section>
<Section icon={<User size={16} color={c.textMuted} />} label={`Members (${memberContacts.length})`}>
<Section icon={<User size={16} color={c.textMuted} />} label={t('contacts.groups.members_with_count', 'Members ({count})', { count: memberContacts.length })}>
{memberContacts.map((m) => {
const email = getContactPrimaryEmail(m);
return (
@@ -1014,12 +1078,12 @@ export default function ContactFormScreen() {
key={m.id}
onRemove={() => updateForm('members', form.members.filter((id) => id !== m.id))}
>
<Text style={styles.memberName} numberOfLines={1}>{getContactDisplayName(m) || 'Unnamed'}</Text>
<Text style={styles.memberName} numberOfLines={1}>{getContactDisplayName(m) || t('contacts.unnamed', 'Unnamed')}</Text>
{!!email && <Text style={styles.memberEmail} numberOfLines={1}>{email}</Text>}
</RemovableRow>
);
})}
<AddButton label="Add members" onPress={() => setMemberPickerOpen(true)} />
<AddButton label={t('contacts.groups.add_members', 'Add members')} onPress={() => setMemberPickerOpen(true)} />
</Section>
{categoriesSection}
@@ -1030,7 +1094,7 @@ export default function ContactFormScreen() {
<ContactPickerSheet
visible={memberPickerOpen}
onClose={() => setMemberPickerOpen(false)}
title="Add members"
title={t('contacts.groups.add_members', 'Add members')}
excludedIds={memberIdSet}
onSelect={(ids) => {
setMemberPickerOpen(false);
@@ -1055,7 +1119,12 @@ export default function ContactFormScreen() {
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{/* Photo + Name preview */}
<View style={styles.photoPanel}>
<Pressable onPress={() => { void pickPhoto(); }} style={styles.photoBtn}>
<Pressable
onPress={() => { void pickPhoto(); }}
style={styles.photoBtn}
accessibilityRole="button"
accessibilityLabel={t('contacts.form.upload_photo', 'Upload photo')}
>
{form.photoUri ? (
<Image source={{ uri: form.photoUri }} style={styles.photoThumb} />
) : (
@@ -1066,9 +1135,11 @@ export default function ContactFormScreen() {
</Pressable>
<View style={{ flex: 1 }}>
<Text style={styles.previewName} numberOfLines={1}>
{previewName || (form.isOrg ? 'New organization' : 'New contact')}
{previewName || (form.isOrg
? t('contacts.form.new_organization', 'New organization')
: t('contacts.create_new', 'New Contact'))}
</Text>
<Text style={styles.previewHint}>Tap photo to choose an image</Text>
<Text style={styles.previewHint}>{t('contacts.form.photo_hint_mobile', 'Tap photo to choose an image')}</Text>
{form.photoUri ? (
<Pressable
onPress={() => {
@@ -1077,8 +1148,9 @@ export default function ContactFormScreen() {
}}
style={styles.removePhotoBtn}
hitSlop={8}
accessibilityRole="button"
>
<Text style={styles.removePhotoLabel}>Remove photo</Text>
<Text style={styles.removePhotoLabel}>{t('contacts.form.remove_photo', 'Remove photo')}</Text>
</Pressable>
) : null}
</View>
@@ -1087,10 +1159,10 @@ export default function ContactFormScreen() {
{addressBookSection}
{/* Identity */}
<Section icon={<User size={16} color={c.textMuted} />} label="Identity">
<Section icon={<User size={16} color={c.textMuted} />} label={t('contacts.form.section_identity', 'Name & Identity')}>
<Pills
value={form.isOrg ? 'org' : 'person'}
options={KIND_OPTIONS}
options={opts.kind}
onChange={(v) => {
const isOrg = v === 'org';
setDirty(true);
@@ -1105,10 +1177,10 @@ export default function ContactFormScreen() {
}}
/>
{form.isOrg ? (
<Field label="Organization name">
<Field label={t('contacts.form.organization', 'Organization')}>
<TextInput
style={styles.input}
placeholder="Company"
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
placeholderTextColor={c.textMuted}
value={form.orgs[0]?.name || ''}
onChangeText={(v) => {
@@ -1121,58 +1193,58 @@ export default function ContactFormScreen() {
) : (
<>
<View style={styles.row2}>
<Field label="Prefix">
<Field label={t('contacts.form.prefix', 'Prefix')}>
<TextInput
style={styles.input}
placeholder="Mr."
placeholder={t('contacts.form.prefix_placeholder', 'Dr., Mr., Mrs.')}
placeholderTextColor={c.textMuted}
value={form.prefix}
onChangeText={(v) => updateForm('prefix', v)}
/>
</Field>
<Field label="Suffix">
<Field label={t('contacts.form.suffix', 'Suffix')}>
<TextInput
style={styles.input}
placeholder="Jr."
placeholder={t('contacts.form.suffix_placeholder', 'Jr., Sr., III')}
placeholderTextColor={c.textMuted}
value={form.suffix}
onChangeText={(v) => updateForm('suffix', v)}
/>
</Field>
</View>
<Field label="First name">
<Field label={t('contacts.form.given_name', 'First name')}>
<TextInput
style={styles.input}
placeholder="First name"
placeholder={t('contacts.form.given_name', 'First name')}
placeholderTextColor={c.textMuted}
value={form.given}
onChangeText={(v) => updateForm('given', v)}
/>
</Field>
<Field label="Middle name">
<Field label={t('contacts.form.middle_name', 'Middle name')}>
<TextInput
style={styles.input}
placeholder="Middle"
placeholder={t('contacts.form.middle_name', 'Middle name')}
placeholderTextColor={c.textMuted}
value={form.middle}
onChangeText={(v) => updateForm('middle', v)}
/>
</Field>
<Field label="Last name">
<Field label={t('contacts.form.surname', 'Last name')}>
<TextInput
style={styles.input}
placeholder="Last name"
placeholder={t('contacts.form.surname', 'Last name')}
placeholderTextColor={c.textMuted}
value={form.surname}
onChangeText={(v) => updateForm('surname', v)}
/>
</Field>
{form.nicknames.map((nick, i) => (
<Field key={i} label={i === 0 ? 'Nickname' : undefined}>
<Field key={i} label={i === 0 ? t('contacts.form.nickname', 'Nickname') : undefined}>
<View style={styles.dateInputRow}>
<TextInput
style={[styles.input, { flex: 1 }]}
placeholder="Nickname"
placeholder={t('contacts.form.nickname_placeholder', 'Nickname')}
placeholderTextColor={c.textMuted}
value={nick}
onChangeText={(v) => {
@@ -1186,6 +1258,8 @@ export default function ContactFormScreen() {
onPress={() => updateForm('nicknames', form.nicknames.filter((_, idx) => idx !== i))}
hitSlop={8}
style={styles.removeBtn}
accessibilityRole="button"
accessibilityLabel={t('common.remove', 'Remove')}
>
<XIcon size={14} color={c.textMuted} />
</Pressable>
@@ -1194,14 +1268,14 @@ export default function ContactFormScreen() {
</Field>
))}
{form.nicknames.every((n) => n.trim()) && (
<AddButton label="Add nickname" onPress={() => updateForm('nicknames', [...form.nicknames, ''])} />
<AddButton label={t('contacts.form.add_nickname', 'Add nickname')} onPress={() => updateForm('nicknames', [...form.nicknames, ''])} />
)}
</>
)}
<Field label="Display name (optional)">
<Field label={t('contacts.form.display_name_optional', 'Display name (optional)')}>
<TextInput
style={styles.input}
placeholder="Custom display name"
placeholder={t('contacts.form.display_name_placeholder', 'Custom display name')}
placeholderTextColor={c.textMuted}
value={form.full}
onChangeText={(v) => updateForm('full', v)}
@@ -1210,7 +1284,7 @@ export default function ContactFormScreen() {
</Section>
{/* Email */}
<Section icon={<Mail size={16} color={c.textMuted} />} label="Email">
<Section icon={<Mail size={16} color={c.textMuted} />} label={t('contacts.form.email', 'Email')}>
{form.emails.map((e, i) => (
<RemovableRow
key={i}
@@ -1218,7 +1292,7 @@ export default function ContactFormScreen() {
>
<TextInput
style={styles.input}
placeholder="email@example.com"
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
placeholderTextColor={c.textMuted}
keyboardType="email-address"
autoCapitalize="none"
@@ -1231,7 +1305,7 @@ export default function ContactFormScreen() {
/>
<Pills
value={e.context}
options={EMAIL_CONTEXTS}
options={opts.contexts}
onChange={(v) => {
const next = [...form.emails];
next[i] = { ...e, context: v };
@@ -1241,13 +1315,13 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add email"
label={t('contacts.form.add_email', 'Add email')}
onPress={() => updateForm('emails', [...form.emails, { address: '', context: '' }])}
/>
</Section>
{/* Phone */}
<Section icon={<Phone size={16} color={c.textMuted} />} label="Phone">
<Section icon={<Phone size={16} color={c.textMuted} />} label={t('contacts.form.phone', 'Phone')}>
{form.phones.map((p, i) => (
<RemovableRow
key={i}
@@ -1255,7 +1329,7 @@ export default function ContactFormScreen() {
>
<TextInput
style={styles.input}
placeholder="+1 555 0100"
placeholder={t('contacts.form.phone_placeholder', '+1 234 567 890')}
placeholderTextColor={c.textMuted}
keyboardType="phone-pad"
value={p.number}
@@ -1267,7 +1341,7 @@ export default function ContactFormScreen() {
/>
<Pills
value={p.feature}
options={PHONE_FEATURES}
options={opts.phoneFeatures}
onChange={(v) => {
const next = [...form.phones];
next[i] = { ...p, feature: v };
@@ -1276,7 +1350,7 @@ export default function ContactFormScreen() {
/>
<Pills
value={p.context}
options={PHONE_CONTEXTS}
options={opts.contexts}
onChange={(v) => {
const next = [...form.phones];
next[i] = { ...p, context: v };
@@ -1286,7 +1360,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add phone"
label={t('contacts.form.add_phone', 'Add phone')}
onPress={() => updateForm('phones', [...form.phones, { number: '', context: '', feature: '' }])}
/>
</Section>
@@ -1294,7 +1368,7 @@ export default function ContactFormScreen() {
{/* Work */}
<Section
icon={<Building size={16} color={c.textMuted} />}
label="Work"
label={t('contacts.form.section_work', 'Work & Organization')}
collapsible
defaultOpen={form.orgs.length > 0}
>
@@ -1304,10 +1378,10 @@ export default function ContactFormScreen() {
onRemove={() => updateForm('orgs', form.orgs.filter((_, idx) => idx !== i))}
>
{!(form.isOrg && i === 0) && (
<Field label="Organization">
<Field label={t('contacts.form.organization', 'Organization')}>
<TextInput
style={styles.input}
placeholder="Company"
placeholder={t('contacts.form.organization_placeholder', 'Company name')}
placeholderTextColor={c.textMuted}
value={o.name}
onChangeText={(v) => {
@@ -1318,10 +1392,10 @@ export default function ContactFormScreen() {
/>
</Field>
)}
<Field label="Department">
<Field label={t('contacts.form.department', 'Department')}>
<TextInput
style={styles.input}
placeholder="Department"
placeholder={t('contacts.form.department_placeholder', 'Department')}
placeholderTextColor={c.textMuted}
value={o.department}
onChangeText={(v) => {
@@ -1331,10 +1405,10 @@ export default function ContactFormScreen() {
}}
/>
</Field>
<Field label="Job title">
<Field label={t('contacts.form.job_title', 'Job title')}>
<TextInput
style={styles.input}
placeholder="Title"
placeholder={t('contacts.form.job_title_placeholder', 'e.g., Software Engineer')}
placeholderTextColor={c.textMuted}
value={o.jobTitle}
onChangeText={(v) => {
@@ -1344,10 +1418,10 @@ export default function ContactFormScreen() {
}}
/>
</Field>
<Field label="Role">
<Field label={t('contacts.form.role', 'Role')}>
<TextInput
style={styles.input}
placeholder="Role"
placeholder={t('contacts.form.role_placeholder', 'e.g., Team Lead')}
placeholderTextColor={c.textMuted}
value={o.role}
onChangeText={(v) => {
@@ -1360,7 +1434,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add organization"
label={t('contacts.form.add_organization', 'Add organization')}
onPress={() => updateForm('orgs', [...form.orgs, { name: '', department: '', jobTitle: '', role: '' }])}
/>
</Section>
@@ -1368,7 +1442,7 @@ export default function ContactFormScreen() {
{/* Address */}
<Section
icon={<MapPin size={16} color={c.textMuted} />}
label="Address"
label={t('contacts.detail.address_default_label', 'Address')}
collapsible
defaultOpen={form.addresses.length > 0}
>
@@ -1379,7 +1453,7 @@ export default function ContactFormScreen() {
>
<TextInput
style={styles.input}
placeholder="Street"
placeholder={t('contacts.form.street', 'Street')}
placeholderTextColor={c.textMuted}
value={a.street}
onChangeText={(v) => {
@@ -1392,7 +1466,7 @@ export default function ContactFormScreen() {
<Field>
<TextInput
style={styles.input}
placeholder="City"
placeholder={t('contacts.form.city', 'City')}
placeholderTextColor={c.textMuted}
value={a.locality}
onChangeText={(v) => {
@@ -1405,7 +1479,7 @@ export default function ContactFormScreen() {
<Field>
<TextInput
style={styles.input}
placeholder="Region"
placeholder={t('contacts.form.region', 'State / Region')}
placeholderTextColor={c.textMuted}
value={a.region}
onChangeText={(v) => {
@@ -1420,7 +1494,7 @@ export default function ContactFormScreen() {
<Field>
<TextInput
style={styles.input}
placeholder="Postcode"
placeholder={t('contacts.form.postcode', 'Postal code')}
placeholderTextColor={c.textMuted}
value={a.postcode}
onChangeText={(v) => {
@@ -1433,7 +1507,7 @@ export default function ContactFormScreen() {
<Field>
<TextInput
style={styles.input}
placeholder="Country"
placeholder={t('contacts.form.country', 'Country')}
placeholderTextColor={c.textMuted}
value={a.country}
onChangeText={(v) => {
@@ -1446,7 +1520,7 @@ export default function ContactFormScreen() {
</View>
<Pills
value={a.context}
options={ADDRESS_CONTEXTS}
options={opts.contexts}
onChange={(v) => {
const next = [...form.addresses];
next[i] = { ...a, context: v };
@@ -1456,7 +1530,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add address"
label={t('contacts.form.add_address', 'Add address')}
onPress={() =>
updateForm('addresses', [...form.addresses, {
street: '', locality: '', region: '', postcode: '', country: '', context: '',
@@ -1468,7 +1542,7 @@ export default function ContactFormScreen() {
{/* Online services */}
<Section
icon={<Globe size={16} color={c.textMuted} />}
label="Online"
label={t('contacts.form.online_services', 'Online Services')}
collapsible
defaultOpen={form.online.length > 0}
>
@@ -1477,10 +1551,10 @@ export default function ContactFormScreen() {
key={i}
onRemove={() => updateForm('online', form.online.filter((_, idx) => idx !== i))}
>
<Field label="URL">
<Field label={t('contacts.form.url', 'URL')}>
<TextInput
style={styles.input}
placeholder="https://example.com/handle"
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={s.uri}
@@ -1492,7 +1566,7 @@ export default function ContactFormScreen() {
/>
</Field>
<View style={styles.row2}>
<Field label="Service">
<Field label={t('contacts.form.service_placeholder', 'Service')}>
<TextInput
style={styles.input}
placeholder="LinkedIn, Mastodon, …"
@@ -1505,10 +1579,10 @@ export default function ContactFormScreen() {
}}
/>
</Field>
<Field label="Label">
<Field label={t('contacts.form.label', 'Label')}>
<TextInput
style={styles.input}
placeholder="Work, Personal, …"
placeholder={t('contacts.form.label_placeholder', 'Work, Personal, …')}
placeholderTextColor={c.textMuted}
value={s.label}
onChangeText={(v) => {
@@ -1522,7 +1596,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add link"
label={t('contacts.form.add_online_service', 'Add online service')}
onPress={() => updateForm('online', [...form.online, { uri: '', service: '', label: '' }])}
/>
</Section>
@@ -1530,7 +1604,7 @@ export default function ContactFormScreen() {
{/* Anniversaries */}
<Section
icon={<Cake size={16} color={c.textMuted} />}
label="Important dates"
label={t('contacts.form.anniversaries', 'Anniversaries')}
collapsible
defaultOpen={form.anniversaries.length > 0}
>
@@ -1546,7 +1620,7 @@ export default function ContactFormScreen() {
{ flex: 1 },
a.date.trim() && !stringToPartialDate(a.date) ? styles.inputInvalid : null,
]}
placeholder="YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD"
placeholder={t('contacts.form.date_placeholder', 'YYYY-MM-DD, YYYY-MM, YYYY or --MM-DD')}
placeholderTextColor={c.textMuted}
value={a.date}
onChangeText={(v) => {
@@ -1559,13 +1633,15 @@ export default function ContactFormScreen() {
onPress={() => setDatePickerIndex(i)}
style={styles.datePickerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.form.pick_date', 'Pick a date')}
>
<Calendar size={18} color={c.primary} />
</Pressable>
</View>
<Pills
value={a.kind}
options={ANNIVERSARY_KINDS}
options={opts.anniversaryKinds}
onChange={(v) => {
const next = [...form.anniversaries];
next[i] = { ...a, kind: v };
@@ -1575,7 +1651,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add date"
label={t('contacts.form.add_anniversary', 'Add date')}
onPress={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
/>
</Section>
@@ -1583,7 +1659,7 @@ export default function ContactFormScreen() {
{/* Personal info */}
<Section
icon={<Heart size={16} color={c.textMuted} />}
label="Personal info"
label={t('contacts.form.personal_info', 'Personal Info')}
collapsible
defaultOpen={form.personalInfo.length > 0}
>
@@ -1594,7 +1670,7 @@ export default function ContactFormScreen() {
>
<TextInput
style={styles.input}
placeholder="Hiking, JavaScript, …"
placeholder={t('contacts.form.personal_info_placeholder', 'e.g., Photography')}
placeholderTextColor={c.textMuted}
value={pi.value}
onChangeText={(v) => {
@@ -1605,7 +1681,7 @@ export default function ContactFormScreen() {
/>
<Pills
value={pi.kind}
options={PERSONAL_INFO_KINDS}
options={opts.personalInfoKinds}
onChange={(v) => {
const next = [...form.personalInfo];
next[i] = { ...pi, kind: v };
@@ -1614,7 +1690,7 @@ export default function ContactFormScreen() {
/>
<Pills
value={pi.level}
options={PERSONAL_INFO_LEVELS}
options={opts.personalInfoLevels}
onChange={(v) => {
const next = [...form.personalInfo];
next[i] = { ...pi, level: v };
@@ -1624,7 +1700,7 @@ export default function ContactFormScreen() {
</RemovableRow>
))}
<AddButton
label="Add"
label={t('contacts.form.add_personal_info', 'Add entry')}
onPress={() => updateForm('personalInfo', [...form.personalInfo, { kind: 'hobby', level: '', value: '' }])}
/>
</Section>
@@ -1633,28 +1709,28 @@ export default function ContactFormScreen() {
{!form.isOrg && (
<Section
icon={<UserCircle size={16} color={c.textMuted} />}
label="Gender"
label={t('contacts.form.gender', 'Gender')}
collapsible
defaultOpen={!!(form.grammaticalGender || form.pronouns)}
>
<Field label="Grammatical gender">
<Field label={t('contacts.form.grammatical_gender', 'Grammatical gender')}>
<Pills
value={form.grammaticalGender}
options={
// Values from other clients (RFC 9554 GRAMGENDER: common,
// neuter, animate, inanimate) stay selectable so a save
// never silently drops them.
form.grammaticalGender && !GENDER_OPTIONS.some((o) => o.value === form.grammaticalGender)
? [...GENDER_OPTIONS, { value: form.grammaticalGender, label: form.grammaticalGender }]
: GENDER_OPTIONS
form.grammaticalGender && !opts.gender.some((o) => o.value === form.grammaticalGender)
? [...opts.gender, { value: form.grammaticalGender, label: form.grammaticalGender }]
: opts.gender
}
onChange={(v) => updateForm('grammaticalGender', v)}
/>
</Field>
<Field label="Pronouns">
<Field label={t('contacts.form.pronouns', 'Pronouns')}>
<TextInput
style={styles.input}
placeholder="they/them"
placeholder={t('contacts.form.pronouns_placeholder', 'they/them')}
placeholderTextColor={c.textMuted}
value={form.pronouns}
onChangeText={(v) => updateForm('pronouns', v)}
@@ -1666,34 +1742,34 @@ export default function ContactFormScreen() {
{/* Calendar */}
<Section
icon={<Calendar size={16} color={c.textMuted} />}
label="Calendar"
label={t('contacts.form.calendar', 'Calendar')}
collapsible
defaultOpen={!!(form.calendarUri || form.schedulingUri || form.freeBusyUri)}
>
<Field label="Calendar URI">
<Field label={t('contacts.form.calendar_uri', 'Calendar URL')}>
<TextInput
style={styles.input}
placeholder="https://…"
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.calendarUri}
onChangeText={(v) => updateForm('calendarUri', v)}
/>
</Field>
<Field label="Scheduling URI">
<Field label={t('contacts.form.scheduling_uri', 'Scheduling URL')}>
<TextInput
style={styles.input}
placeholder="https://…"
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.schedulingUri}
onChangeText={(v) => updateForm('schedulingUri', v)}
/>
</Field>
<Field label="Free/Busy URI">
<Field label={t('contacts.form.freebusy_uri', 'Free/Busy URL')}>
<TextInput
style={styles.input}
placeholder="https://…"
placeholder={t('contacts.form.url_placeholder', 'https://...')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
value={form.freeBusyUri}
@@ -1731,7 +1807,7 @@ export default function ContactFormScreen() {
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setDatePickerIndex(null)} hitSlop={8}>
<Text style={styles.pickerDone}>Done</Text>
<Text style={styles.pickerDone}>{t('common.done', 'Done')}</Text>
</Pressable>
</View>
<DateTimePicker
+90 -30
View File
@@ -41,6 +41,7 @@ import {
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -75,22 +76,28 @@ function groupContacts(sorted: ContactCard[], byLastName: boolean): Section[] {
.map((title) => ({ title, data: groups[title] }));
}
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
function categoryTitle(
category: ContactCategory,
books: Array<{ id: string; name: string }>,
groups: ContactCard[],
t: TranslateFn,
): string {
switch (category.type) {
case 'all':
return 'All Contacts';
return t('contacts.all_contacts', 'All Contacts');
case 'addressBook':
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
return books.find((b) => b.id === category.addressBookId)?.name
|| t('contacts.address_books.address_book', 'Address Book');
case 'group': {
const g = groups.find((c) => c.id === category.groupId);
return g ? getContactDisplayName(g) || 'Group' : 'Group';
return (g && getContactDisplayName(g)) || t('contacts.group', 'Group');
}
case 'keyword':
return `#${category.keyword}`;
case 'uncategorized':
return 'Uncategorized';
return t('contacts.no_category', 'No Category');
default:
return 'Contacts';
return t('contacts.title', 'Contacts');
}
}
@@ -98,6 +105,7 @@ export default function ContactsScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const t = useLocaleStore((s) => s.t);
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
const loading = useContactsStore((s) => s.loading);
@@ -241,13 +249,13 @@ export default function ContactsScreen() {
await Sharing.shareAsync(path, {
mimeType: 'text/vcard',
UTI: 'public.vcard',
dialogTitle: 'Export contacts',
dialogTitle: t('contacts.export.title', 'Export Contacts'),
});
} else {
await Share.share({ message: vcf });
}
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.export.failed', 'Export failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -259,10 +267,16 @@ export default function ContactsScreen() {
);
const openNewMenu = () => {
Alert.alert('Create', undefined, [
{ text: 'New contact', onPress: () => navigation.navigate('ContactForm', { addressBookId: createBookId }) },
{ text: 'New group', onPress: () => navigation.navigate('ContactForm', { asGroup: true, addressBookId: createBookId }) },
{ text: 'Cancel', style: 'cancel' },
Alert.alert(t('common.create', 'Create'), undefined, [
{
text: t('contacts.create_new', 'New Contact'),
onPress: () => navigation.navigate('ContactForm', { addressBookId: createBookId }),
},
{
text: t('contacts.groups.create', 'New Group'),
onPress: () => navigation.navigate('ContactForm', { asGroup: true, addressBookId: createBookId }),
},
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
]);
};
@@ -280,7 +294,7 @@ export default function ContactsScreen() {
try {
await moveContactsToAddressBook(ids, bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.address_books.move_failed', 'Failed to move contact'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -291,7 +305,7 @@ export default function ContactsScreen() {
try {
await addKeywordToContacts(ids, keyword);
} catch (err) {
Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.bulk.tag_failed', 'Tagging failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -305,14 +319,14 @@ export default function ContactsScreen() {
[addressBooks, importTargetBookId],
);
const title = categoryTitle(selectedCategory, addressBooks, groups);
const title = categoryTitle(selectedCategory, addressBooks, groups, t);
// Top chip row: All / per-address-book / Groups
const chips = React.useMemo(() => {
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
{
key: 'all',
label: 'All',
label: t('contacts.tabs.all', 'All'),
category: { type: 'all' },
active: selectedCategory.type === 'all',
},
@@ -328,21 +342,21 @@ export default function ContactsScreen() {
if (groups.length > 0) {
items.push({
key: 'groups-divider',
label: 'Groups',
label: t('contacts.tabs.groups', 'Groups'),
category: { type: 'all' },
active: false,
});
for (const g of groups) {
items.push({
key: `group:${g.id}`,
label: getContactDisplayName(g) || 'Group',
label: getContactDisplayName(g) || t('contacts.group', 'Group'),
category: { type: 'group', groupId: g.id },
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
});
}
}
return items;
}, [addressBooks, groups, selectedCategory]);
}, [addressBooks, groups, selectedCategory, t]);
return (
<SafeAreaView style={styles.container} edges={['top']}>
@@ -351,15 +365,25 @@ export default function ContactsScreen() {
<View style={styles.header}>
{selectionMode ? (
<>
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
<Pressable
onPress={clearSelection}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.clear', 'Clear selection')}
>
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<Text style={styles.headerTitle}>
{t('contacts.bulk.selected', '{count, plural, one {1 selected} other {# selected}}', { count: selection.size })}
</Text>
<View style={styles.headerActions}>
<Pressable
onPress={selectAllVisible}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.select_all', 'Select all')}
>
<CheckSquare size={20} color={c.text} />
</Pressable>
@@ -367,6 +391,8 @@ export default function ContactsScreen() {
onPress={() => { void exportSelected(); }}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.export', 'Export')}
>
<Share2 size={20} color={c.text} />
</Pressable>
@@ -375,6 +401,8 @@ export default function ContactsScreen() {
onPress={() => setMoveSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.move_to_address_book', 'Move to address book')}
>
<FolderInput size={20} color={c.text} />
</Pressable>
@@ -383,6 +411,8 @@ export default function ContactsScreen() {
onPress={() => setTagSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.add_tag', 'Add tag')}
>
<Tag size={20} color={c.text} />
</Pressable>
@@ -390,6 +420,8 @@ export default function ContactsScreen() {
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.bulk.delete', 'Delete')}
>
<Trash2 size={20} color={c.error} />
</Pressable>
@@ -397,7 +429,13 @@ export default function ContactsScreen() {
</>
) : (
<>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
<Pressable
onPress={() => setDrawerOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.open_categories', 'Open categories')}
>
<Menu size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
@@ -407,6 +445,8 @@ export default function ContactsScreen() {
onPress={() => setSearchActive((v) => !v)}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.search', 'Search')}
>
<Search size={20} color={c.text} />
</Pressable>
@@ -414,6 +454,8 @@ export default function ContactsScreen() {
onPress={openImport}
style={styles.headerIconBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.import_vcard', 'Import vCard')}
>
<Upload size={20} color={c.text} />
</Pressable>
@@ -422,6 +464,9 @@ export default function ContactsScreen() {
onLongPress={openNewMenu}
style={styles.addBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.create_new', 'New Contact')}
accessibilityHint={t('contacts.create_hint_mobile', 'Long-press to create a group instead')}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
@@ -435,14 +480,19 @@ export default function ContactsScreen() {
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
placeholderTextColor={c.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
<Pressable
onPress={() => setSearchQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
>
<X size={16} color={c.textMuted} />
</Pressable>
)}
@@ -464,6 +514,8 @@ export default function ContactsScreen() {
key={chip.key}
onPress={() => setSelectedCategory(chip.category)}
style={[styles.chip, chip.active && styles.chipActive]}
accessibilityRole="button"
accessibilityState={{ selected: chip.active }}
>
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
{chip.label}
@@ -521,10 +573,14 @@ export default function ContactsScreen() {
<View style={styles.emptyState}>
<UserCircle size={48} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>
{searchQuery ? 'No contacts found' : 'No contacts yet'}
{searchQuery
? t('contacts.empty_search', 'No contacts match your search')
: t('contacts.empty_state_title', 'No contacts yet')}
</Text>
<Text style={styles.emptySubtitle}>
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
{searchQuery
? t('contacts.empty_search_hint', 'Try a different search term')
: t('contacts.empty_state_hint_mobile', 'Tap + to add one')}
</Text>
</View>
)
@@ -533,10 +589,14 @@ export default function ContactsScreen() {
<Dialog
visible={confirmBulkDelete}
title="Delete contacts"
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
title={t('contacts.bulk.delete_confirm_title', 'Delete contacts')}
message={t(
'contacts.bulk.delete_confirm_mobile',
'{count, plural, one {Delete # contact? This cannot be undone.} other {Delete # contacts? This cannot be undone.}}',
{ count: selection.size },
)}
variant="destructive"
confirmText="Delete"
confirmText={t('contacts.bulk.delete', 'Delete')}
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
@@ -566,7 +626,7 @@ export default function ContactsScreen() {
visible={importTargetOpen}
onClose={() => setImportTargetOpen(false)}
currentBookId={importTargetBookId}
title="Import into address book"
title={t('contacts.import.into_address_book', 'Import into address book')}
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
/>
</SafeAreaView>
+57 -24
View File
@@ -24,6 +24,7 @@ import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
import Dialog from '../components/Dialog';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
type Nav = NativeStackNavigationProp<RootStackParamList, 'GroupDetail'>;
type Route = RouteProp<RootStackParamList, 'GroupDetail'>;
@@ -33,6 +34,7 @@ export default function GroupDetailScreen() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const navigation = useNavigation<Nav>();
const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t);
const { groupId } = route.params;
const allContacts = useContactsStore((s) => s.contacts);
@@ -57,25 +59,34 @@ export default function GroupDetailScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Group</Text>
<Text style={styles.headerTitle}>{t('contacts.group', 'Group')}</Text>
</View>
<View style={styles.missing}>
<Text style={styles.missingText}>Group not found</Text>
<Text style={styles.missingText}>{t('contacts.groups.not_found', 'Group not found')}</Text>
</View>
</SafeAreaView>
);
}
const name = getContactDisplayName(group) || 'Group';
const name = getContactDisplayName(group) || t('contacts.group', 'Group');
const emailAll = () => {
// One recipient per address: two members sharing a mailbox are sent once.
const recipients: EmailAddress[] = getGroupRecipients(group.id);
if (recipients.length === 0) {
Alert.alert('No emails', 'None of the members have an email address.');
Alert.alert(
t('contacts.groups.no_emails', 'No emails'),
t('contacts.groups.no_member_emails', 'This group has no members with an email address.'),
);
return;
}
navigation.navigate('Compose', { prefillTo: recipients });
@@ -85,7 +96,7 @@ export default function GroupDetailScreen() {
try {
await addContactsToGroup(group.id, ids);
} catch (err) {
Alert.alert('Failed to add members', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.groups.add_members_failed', 'Failed to add members'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -102,7 +113,7 @@ export default function GroupDetailScreen() {
try {
await updateContact(group.id, { members: existing });
} catch (err) {
Alert.alert('Failed to remove member', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.groups.remove_member_failed', 'Failed to remove member'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -112,7 +123,7 @@ export default function GroupDetailScreen() {
await deleteContact(group.id);
navigation.goBack();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
Alert.alert(t('contacts.groups.delete_failed', 'Failed to delete group'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
}
};
@@ -121,7 +132,13 @@ export default function GroupDetailScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
@@ -130,10 +147,18 @@ export default function GroupDetailScreen() {
onPress={() => navigation.navigate('ContactForm', { contactId: group.id, asGroup: true })}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.groups.edit', 'Edit Group')}
>
<Edit2 size={18} color={c.text} />
</Pressable>
<Pressable onPress={() => setConfirmDelete(true)} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={() => setConfirmDelete(true)}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.groups.delete_confirm_title', 'Delete group')}
>
<Trash2 size={18} color={c.error} />
</Pressable>
</View>
@@ -145,18 +170,22 @@ export default function GroupDetailScreen() {
</View>
<Text style={styles.heroName}>{name}</Text>
<Text style={styles.heroSubtitle}>
{members.length} member{members.length === 1 ? '' : 's'}
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: members.length })}
</Text>
</View>
<View style={styles.actionsRow}>
<Pressable onPress={emailAll} style={styles.actionBtn}>
<Pressable onPress={emailAll} style={styles.actionBtn} accessibilityRole="button">
<Mail size={16} color={c.primary} />
<Text style={styles.actionLabel}>Email all</Text>
<Text style={styles.actionLabel}>{t('contacts.groups.email_all', 'Email all')}</Text>
</Pressable>
<Pressable onPress={() => setPickerOpen(true)} style={[styles.actionBtn, styles.actionBtnPrimary]}>
<Pressable
onPress={() => setPickerOpen(true)}
style={[styles.actionBtn, styles.actionBtnPrimary]}
accessibilityRole="button"
>
<Plus size={16} color={c.primaryForeground} />
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>Add member</Text>
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>{t('contacts.groups.add_member', 'Add member')}</Text>
</Pressable>
</View>
@@ -175,6 +204,8 @@ export default function GroupDetailScreen() {
onPress={() => setRemovingMember(item)}
style={styles.removeMemberBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.groups.remove_member', 'Remove member')}
>
<UserMinus size={16} color={c.error} />
</Pressable>
@@ -184,8 +215,8 @@ export default function GroupDetailScreen() {
ListEmptyComponent={
<View style={styles.empty}>
<Users size={40} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No members yet</Text>
<Text style={styles.emptySubtitle}>Tap "Add member" to get started</Text>
<Text style={styles.emptyTitle}>{t('contacts.groups.no_members', 'No members in this group')}</Text>
<Text style={styles.emptySubtitle}>{t('contacts.groups.no_members_hint', 'Tap "Add member" to get started')}</Text>
</View>
}
contentContainerStyle={{ paddingBottom: spacing.xxxl }}
@@ -195,27 +226,29 @@ export default function GroupDetailScreen() {
visible={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(ids) => { void addMembers(ids); }}
title="Add members"
title={t('contacts.groups.add_members', 'Add members')}
excludedIds={excludedIds}
multi
/>
<Dialog
visible={confirmDelete}
title="Delete group"
message={`Delete "${name}"? Members will not be deleted.`}
title={t('contacts.groups.delete_confirm_title', 'Delete group')}
message={t('contacts.groups.delete_confirm_named', 'Delete "{name}"? Members will not be deleted.', { name })}
variant="destructive"
confirmText="Delete"
confirmText={t('contacts.context_menu.delete', 'Delete')}
onConfirm={doDelete}
onCancel={() => setConfirmDelete(false)}
/>
<Dialog
visible={!!removingMember}
title="Remove member"
message={`Remove "${removingMember ? getContactDisplayName(removingMember) : ''}" from this group?`}
title={t('contacts.groups.remove_member', 'Remove member')}
message={t('contacts.groups.remove_member_confirm', 'Remove "{name}" from this group?', {
name: removingMember ? getContactDisplayName(removingMember) : '',
})}
variant="destructive"
confirmText="Remove"
confirmText={t('common.remove', 'Remove')}
onConfirm={() => {
if (removingMember) void removeMember(removingMember);
setRemovingMember(null);