mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
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:
+94
-2
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user