mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06:39 +00:00
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.
755 lines
26 KiB
TypeScript
755 lines
26 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
SectionList,
|
|
Pressable,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
RefreshControl,
|
|
ScrollView,
|
|
Alert,
|
|
Share,
|
|
} from 'react-native';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import * as Sharing from 'expo-sharing';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import {
|
|
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2,
|
|
} from 'lucide-react-native';
|
|
import type { RootStackParamList } from '../navigation/types';
|
|
import type { ContactCard } from '../api/types';
|
|
import {
|
|
useContactsStore,
|
|
sortContactsByName,
|
|
selectCreateTargetBookId,
|
|
selectGroupMembers,
|
|
selectUncategorized,
|
|
type ContactCategory,
|
|
} from '../stores/contacts-store';
|
|
import { getContactDisplayName, getContactSortName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
|
import { contactsToVCard } from '../lib/vcard';
|
|
import {
|
|
ContactListRow,
|
|
AddressBookPickerSheet,
|
|
ContactImportSheet,
|
|
TagAssignSheet,
|
|
} from '../components/contacts';
|
|
import Dialog from '../components/Dialog';
|
|
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
|
import { useSettingsStore } from '../stores/settings-store';
|
|
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
|
import { useColors } from '../theme/colors';
|
|
|
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|
|
|
interface Section {
|
|
title: string;
|
|
data: ContactCard[];
|
|
}
|
|
|
|
/** Letter sections for contacts already in `sortContactsByName` order. */
|
|
function groupContacts(sorted: ContactCard[], byLastName: boolean): Section[] {
|
|
const groups: Record<string, ContactCard[]> = {};
|
|
for (const c of sorted) {
|
|
// Sections follow the sort name, so with "sort by last name" Alice Smith
|
|
// files under S.
|
|
const name = getContactSortName(c, byLastName).trim();
|
|
// Any letter (Ä, É, Ł, 王…) gets its own section like the webmail; only
|
|
// digits and symbols fall into "#". Hermes supports Unicode property escapes.
|
|
const first = Array.from(name)[0] || '#';
|
|
const letter = first.toLocaleUpperCase();
|
|
const key = /\p{L}/u.test(letter) ? letter : '#';
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(c);
|
|
}
|
|
return Object.keys(groups)
|
|
.sort((a, b) => {
|
|
if (a === '#') return 1;
|
|
if (b === '#') return -1;
|
|
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
|
})
|
|
.map((title) => ({ title, data: groups[title] }));
|
|
}
|
|
|
|
function categoryTitle(
|
|
category: ContactCategory,
|
|
books: Array<{ id: string; name: string }>,
|
|
groups: ContactCard[],
|
|
t: TranslateFn,
|
|
): string {
|
|
switch (category.type) {
|
|
case 'all':
|
|
return t('contacts.all_contacts', 'All Contacts');
|
|
case 'addressBook':
|
|
return books.find((b) => b.id === category.addressBookId)?.name
|
|
|| t('contacts.address_books.address_book', 'Address Book');
|
|
case 'group': {
|
|
const g = groups.find((c) => c.id === category.groupId);
|
|
return (g && getContactDisplayName(g)) || t('contacts.group', 'Group');
|
|
}
|
|
case 'keyword':
|
|
return `#${category.keyword}`;
|
|
case 'uncategorized':
|
|
return t('contacts.no_category', 'No Category');
|
|
default:
|
|
return t('contacts.title', 'Contacts');
|
|
}
|
|
}
|
|
|
|
export default function ContactsScreen() {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const navigation = useNavigation<Nav>();
|
|
const t = useLocaleStore((s) => s.t);
|
|
const contacts = useContactsStore((s) => s.contacts);
|
|
const addressBooks = useContactsStore((s) => s.addressBooks);
|
|
const loading = useContactsStore((s) => s.loading);
|
|
const error = useContactsStore((s) => s.error);
|
|
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
|
const hydrated = useContactsStore((s) => s.hydrated);
|
|
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
|
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
|
const fetchContactsIfStale = useContactsStore((s) => s.fetchContactsIfStale);
|
|
const hydrate = useContactsStore((s) => s.hydrate);
|
|
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
|
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
|
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
|
|
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
|
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
|
|
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
|
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
|
|
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
const [searchActive, setSearchActive] = React.useState(false);
|
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
|
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
|
const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
|
|
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
|
|
const [importOpen, setImportOpen] = React.useState(false);
|
|
const [importTargetOpen, setImportTargetOpen] = React.useState(false);
|
|
const [importTargetBookId, setImportTargetBookId] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
void hydrate();
|
|
}, [hydrate]);
|
|
|
|
// The cards loaded at startup and follow live changes; only download them
|
|
// again when that copy is stale. Pull to refresh still forces a reload.
|
|
React.useEffect(() => {
|
|
void fetchAddressBooks();
|
|
void fetchContactsIfStale();
|
|
}, [fetchAddressBooks, fetchContactsIfStale]);
|
|
|
|
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
|
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
|
|
|
const categoryContacts = React.useMemo(() => {
|
|
let filtered: ContactCard[];
|
|
switch (selectedCategory.type) {
|
|
case 'all':
|
|
filtered = individuals;
|
|
break;
|
|
case 'addressBook':
|
|
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
|
|
break;
|
|
case 'group':
|
|
filtered = selectGroupMembers(
|
|
{ contacts } as Parameters<typeof selectGroupMembers>[0],
|
|
selectedCategory.groupId,
|
|
).filter((c) => !isGroup(c));
|
|
break;
|
|
case 'keyword':
|
|
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
|
break;
|
|
case 'uncategorized':
|
|
// "No category" = contacts without a keyword, like the webmail.
|
|
filtered = selectUncategorized(individuals);
|
|
break;
|
|
default:
|
|
filtered = individuals;
|
|
}
|
|
return filtered;
|
|
}, [contacts, individuals, selectedCategory]);
|
|
|
|
// Sort once per list or order change; search only filters the sorted list,
|
|
// so typing never re-sorts the address book (PF10).
|
|
const sorted = React.useMemo(
|
|
() => sortContactsByName(categoryContacts, sortByLastName),
|
|
[categoryContacts, sortByLastName],
|
|
);
|
|
|
|
const visible = React.useMemo(() => {
|
|
if (!searchQuery) return sorted;
|
|
return sorted.filter((c) => matchesContactSearch(c, searchQuery));
|
|
}, [sorted, searchQuery]);
|
|
|
|
const sections = React.useMemo<Section[]>(() => {
|
|
if (groupByLetter) return groupContacts(visible, sortByLastName);
|
|
// Flat list - single unnamed section keeps SectionList rendering simple.
|
|
return [{ title: '', data: visible }];
|
|
}, [visible, groupByLetter, sortByLastName]);
|
|
|
|
const selectionMode = selection.size > 0;
|
|
const toggleSelect = (id: string) => {
|
|
setSelection((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleRowPress = (c: ContactCard) => {
|
|
if (selectionMode) {
|
|
toggleSelect(c.id);
|
|
return;
|
|
}
|
|
if (isGroup(c)) {
|
|
navigation.navigate('GroupDetail', { groupId: c.id });
|
|
} else {
|
|
navigation.navigate('ContactDetail', { contactId: c.id });
|
|
}
|
|
};
|
|
|
|
const handleLongPress = (c: ContactCard) => {
|
|
toggleSelect(c.id);
|
|
};
|
|
|
|
const clearSelection = () => setSelection(new Set());
|
|
|
|
const doBulkDelete = async () => {
|
|
const ids = Array.from(selection);
|
|
setConfirmBulkDelete(false);
|
|
clearSelection();
|
|
await bulkDelete(ids);
|
|
};
|
|
|
|
const selectAllVisible = () => {
|
|
setSelection((prev) => {
|
|
const allSelected = visible.length > 0 && visible.every((c) => prev.has(c.id));
|
|
return allSelected ? new Set() : new Set(visible.map((c) => c.id));
|
|
});
|
|
};
|
|
|
|
const exportSelected = async () => {
|
|
const selected = contacts.filter((c) => selection.has(c.id));
|
|
if (selected.length === 0) return;
|
|
const vcf = contactsToVCard(selected);
|
|
try {
|
|
const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`;
|
|
const path = `${FileSystem.cacheDirectory}${filename}`;
|
|
await FileSystem.writeAsStringAsync(path, vcf);
|
|
if (await Sharing.isAvailableAsync()) {
|
|
await Sharing.shareAsync(path, {
|
|
mimeType: 'text/vcard',
|
|
UTI: 'public.vcard',
|
|
dialogTitle: t('contacts.export.title', 'Export Contacts'),
|
|
});
|
|
} else {
|
|
await Share.share({ message: vcf });
|
|
}
|
|
} catch (err) {
|
|
Alert.alert(t('contacts.export.failed', 'Export failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
|
|
}
|
|
};
|
|
|
|
// A new contact or group lands in the address book being viewed, not the
|
|
// default one.
|
|
const createBookId = React.useMemo(
|
|
() => selectCreateTargetBookId(selectedCategory, addressBooks),
|
|
[selectedCategory, addressBooks],
|
|
);
|
|
|
|
const openNewMenu = () => {
|
|
Alert.alert(t('common.create', 'Create'), undefined, [
|
|
{
|
|
text: t('contacts.create_new', 'New Contact'),
|
|
onPress: () => navigation.navigate('ContactForm', { addressBookId: createBookId }),
|
|
},
|
|
{
|
|
text: t('contacts.groups.create', 'New Group'),
|
|
onPress: () => navigation.navigate('ContactForm', { asGroup: true, addressBookId: createBookId }),
|
|
},
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
]);
|
|
};
|
|
|
|
// Default target for moves/imports: the currently-viewed address book, else
|
|
// the account's default book.
|
|
const defaultBookId = React.useMemo(() => {
|
|
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
|
|
return getDefaultAddressBookId();
|
|
}, [selectedCategory, addressBooks, getDefaultAddressBookId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const handleBulkMove = async (bookId: string) => {
|
|
const ids = Array.from(selection);
|
|
setMoveSheetOpen(false);
|
|
clearSelection();
|
|
try {
|
|
await moveContactsToAddressBook(ids, bookId);
|
|
} catch (err) {
|
|
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 handleBulkTag = async (keyword: string) => {
|
|
const ids = Array.from(selection);
|
|
setTagSheetOpen(false);
|
|
clearSelection();
|
|
try {
|
|
await addKeywordToContacts(ids, keyword);
|
|
} catch (err) {
|
|
Alert.alert(t('contacts.bulk.tag_failed', 'Tagging failed'), err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'));
|
|
}
|
|
};
|
|
|
|
const openImport = () => {
|
|
setImportTargetBookId(defaultBookId);
|
|
setImportOpen(true);
|
|
};
|
|
|
|
const importTargetName = React.useMemo(
|
|
() => addressBooks.find((b) => b.id === importTargetBookId)?.name,
|
|
[addressBooks, importTargetBookId],
|
|
);
|
|
|
|
const title = categoryTitle(selectedCategory, addressBooks, groups, t);
|
|
|
|
// Top chip row: All / per-address-book / Groups
|
|
const chips = React.useMemo(() => {
|
|
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
|
|
{
|
|
key: 'all',
|
|
label: t('contacts.tabs.all', 'All'),
|
|
category: { type: 'all' },
|
|
active: selectedCategory.type === 'all',
|
|
},
|
|
];
|
|
for (const book of addressBooks) {
|
|
items.push({
|
|
key: `book:${book.id}`,
|
|
label: book.name,
|
|
category: { type: 'addressBook', addressBookId: book.id },
|
|
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
|
|
});
|
|
}
|
|
if (groups.length > 0) {
|
|
items.push({
|
|
key: 'groups-divider',
|
|
label: t('contacts.tabs.groups', 'Groups'),
|
|
category: { type: 'all' },
|
|
active: false,
|
|
});
|
|
for (const g of groups) {
|
|
items.push({
|
|
key: `group:${g.id}`,
|
|
label: getContactDisplayName(g) || t('contacts.group', 'Group'),
|
|
category: { type: 'group', groupId: g.id },
|
|
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
}, [addressBooks, groups, selectedCategory, t]);
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
|
|
|
<View style={styles.header}>
|
|
{selectionMode ? (
|
|
<>
|
|
<Pressable
|
|
onPress={clearSelection}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.clear', 'Clear selection')}
|
|
>
|
|
<X size={22} color={c.text} />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>
|
|
{t('contacts.bulk.selected', '{count, plural, one {1 selected} other {# selected}}', { count: selection.size })}
|
|
</Text>
|
|
<View style={styles.headerActions}>
|
|
<Pressable
|
|
onPress={selectAllVisible}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.select_all', 'Select all')}
|
|
>
|
|
<CheckSquare size={20} color={c.text} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => { void exportSelected(); }}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.export', 'Export')}
|
|
>
|
|
<Share2 size={20} color={c.text} />
|
|
</Pressable>
|
|
{addressBooks.length > 0 && (
|
|
<Pressable
|
|
onPress={() => setMoveSheetOpen(true)}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.move_to_address_book', 'Move to address book')}
|
|
>
|
|
<FolderInput size={20} color={c.text} />
|
|
</Pressable>
|
|
)}
|
|
<Pressable
|
|
onPress={() => setTagSheetOpen(true)}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.add_tag', 'Add tag')}
|
|
>
|
|
<Tag size={20} color={c.text} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setConfirmBulkDelete(true)}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.bulk.delete', 'Delete')}
|
|
>
|
|
<Trash2 size={20} color={c.error} />
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pressable
|
|
onPress={() => setDrawerOpen(true)}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.open_categories', 'Open categories')}
|
|
>
|
|
<Menu size={22} color={c.text} />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
|
<Text style={styles.headerCount}>{visible.length}</Text>
|
|
<View style={styles.headerActions}>
|
|
<Pressable
|
|
onPress={() => setSearchActive((v) => !v)}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.search', 'Search')}
|
|
>
|
|
<Search size={20} color={c.text} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={openImport}
|
|
style={styles.headerIconBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.import_vcard', 'Import vCard')}
|
|
>
|
|
<Upload size={20} color={c.text} />
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => navigation.navigate('ContactForm', { addressBookId: createBookId })}
|
|
onLongPress={openNewMenu}
|
|
style={styles.addBtn}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.create_new', 'New Contact')}
|
|
accessibilityHint={t('contacts.create_hint_mobile', 'Long-press to create a group instead')}
|
|
>
|
|
<Plus size={18} color={c.primaryForeground} />
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{searchActive && (
|
|
<View style={styles.searchBar}>
|
|
<Search size={16} color={c.textMuted} />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder={t('contacts.search_placeholder', 'Search contacts...')}
|
|
placeholderTextColor={c.textMuted}
|
|
value={searchQuery}
|
|
onChangeText={setSearchQuery}
|
|
autoFocus
|
|
/>
|
|
{searchQuery.length > 0 && (
|
|
<Pressable
|
|
onPress={() => setSearchQuery('')}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
|
|
>
|
|
<X size={16} color={c.textMuted} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{!selectionMode && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.chipBar}
|
|
contentContainerStyle={styles.chipBarContent}
|
|
>
|
|
{chips.map((chip) =>
|
|
chip.key === 'groups-divider' ? (
|
|
<View key={chip.key} style={styles.chipDivider} />
|
|
) : (
|
|
<Pressable
|
|
key={chip.key}
|
|
onPress={() => setSelectedCategory(chip.category)}
|
|
style={[styles.chip, chip.active && styles.chipActive]}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: chip.active }}
|
|
>
|
|
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
|
{chip.label}
|
|
</Text>
|
|
</Pressable>
|
|
),
|
|
)}
|
|
</ScrollView>
|
|
)}
|
|
|
|
{error && (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<SectionList
|
|
sections={sections}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<ContactListRow
|
|
contact={item}
|
|
onPress={() => handleRowPress(item)}
|
|
onLongPress={() => handleLongPress(item)}
|
|
selected={selection.has(item.id)}
|
|
selectionMode={selectionMode}
|
|
/>
|
|
)}
|
|
renderSectionHeader={({ section }) =>
|
|
section.title ? (
|
|
<View style={styles.sectionHeader}>
|
|
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
|
</View>
|
|
) : null
|
|
}
|
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
stickySectionHeadersEnabled
|
|
contentContainerStyle={styles.listContent}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={loading && hydrated}
|
|
onRefresh={() => {
|
|
void fetchContacts();
|
|
void fetchAddressBooks();
|
|
}}
|
|
tintColor={c.primary}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
loading ? (
|
|
<View style={styles.emptyState}>
|
|
<ActivityIndicator color={c.primary} />
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyState}>
|
|
<UserCircle size={48} color={c.surfaceActive} />
|
|
<Text style={styles.emptyTitle}>
|
|
{searchQuery
|
|
? t('contacts.empty_search', 'No contacts match your search')
|
|
: t('contacts.empty_state_title', 'No contacts yet')}
|
|
</Text>
|
|
<Text style={styles.emptySubtitle}>
|
|
{searchQuery
|
|
? t('contacts.empty_search_hint', 'Try a different search term')
|
|
: t('contacts.empty_state_hint_mobile', 'Tap + to add one')}
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
/>
|
|
|
|
<Dialog
|
|
visible={confirmBulkDelete}
|
|
title={t('contacts.bulk.delete_confirm_title', 'Delete contacts')}
|
|
message={t(
|
|
'contacts.bulk.delete_confirm_mobile',
|
|
'{count, plural, one {Delete # contact? This cannot be undone.} other {Delete # contacts? This cannot be undone.}}',
|
|
{ count: selection.size },
|
|
)}
|
|
variant="destructive"
|
|
confirmText={t('contacts.bulk.delete', 'Delete')}
|
|
onConfirm={doBulkDelete}
|
|
onCancel={() => setConfirmBulkDelete(false)}
|
|
/>
|
|
|
|
<AddressBookPickerSheet
|
|
visible={moveSheetOpen}
|
|
onClose={() => setMoveSheetOpen(false)}
|
|
onPick={(id) => { void handleBulkMove(id); }}
|
|
/>
|
|
|
|
<TagAssignSheet
|
|
visible={tagSheetOpen}
|
|
onClose={() => setTagSheetOpen(false)}
|
|
onPick={(kw) => { void handleBulkTag(kw); }}
|
|
/>
|
|
|
|
<ContactImportSheet
|
|
visible={importOpen}
|
|
onClose={() => setImportOpen(false)}
|
|
targetBookId={importTargetBookId}
|
|
targetBookName={importTargetName}
|
|
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
|
|
onImported={() => { void fetchContacts(); }}
|
|
/>
|
|
|
|
<AddressBookPickerSheet
|
|
visible={importTargetOpen}
|
|
onClose={() => setImportTargetOpen(false)}
|
|
currentBookId={importTargetBookId}
|
|
title={t('contacts.import.into_address_book', 'Import into address book')}
|
|
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
function makeStyles(c: ThemePalette) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: c.background },
|
|
listContent: { paddingBottom: 80 },
|
|
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
gap: spacing.sm,
|
|
},
|
|
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
|
|
headerCount: {
|
|
...typography.small,
|
|
color: c.textMuted,
|
|
backgroundColor: c.surface,
|
|
borderRadius: radius.full,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
overflow: 'hidden',
|
|
},
|
|
headerActions: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
headerIconBtn: {
|
|
width: 40,
|
|
height: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: radius.full,
|
|
},
|
|
addBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: c.primary,
|
|
borderRadius: radius.full,
|
|
},
|
|
|
|
searchBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginHorizontal: spacing.lg,
|
|
marginBottom: spacing.md,
|
|
paddingHorizontal: spacing.md,
|
|
height: componentSizes.inputHeight,
|
|
backgroundColor: c.background,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
gap: spacing.sm,
|
|
},
|
|
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
|
|
|
|
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
|
chipBarContent: {
|
|
paddingHorizontal: spacing.lg,
|
|
gap: spacing.sm,
|
|
alignItems: 'center',
|
|
},
|
|
chip: {
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.xs,
|
|
borderRadius: radius.full,
|
|
backgroundColor: c.surface,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
},
|
|
chipActive: {
|
|
backgroundColor: c.primary,
|
|
borderColor: c.primary,
|
|
},
|
|
chipText: { ...typography.captionMedium, color: c.textSecondary },
|
|
chipTextActive: { color: c.primaryForeground },
|
|
chipDivider: {
|
|
width: 1,
|
|
height: 16,
|
|
backgroundColor: c.border,
|
|
marginHorizontal: spacing.xs,
|
|
},
|
|
|
|
sectionHeader: {
|
|
backgroundColor: c.background,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.xs,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: c.borderLight,
|
|
},
|
|
sectionHeaderText: { ...typography.small, color: c.primary },
|
|
|
|
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
|
|
|
errorBanner: {
|
|
marginHorizontal: spacing.lg,
|
|
marginBottom: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radius.sm,
|
|
backgroundColor: c.errorBg,
|
|
borderWidth: 1,
|
|
borderColor: c.errorBorder,
|
|
},
|
|
errorText: { ...typography.caption, color: c.errorForeground },
|
|
|
|
emptyState: {
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.xxxl * 2,
|
|
gap: spacing.sm,
|
|
},
|
|
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
|
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
|
});
|
|
}
|