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