feat: show contacts as list and details side by side on tablets

This commit is contained in:
Linus Rath
2026-10-15 02:06:50 +02:00
parent f4ff07da7a
commit 64a62b6306
7 changed files with 217 additions and 25 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab'; import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen'; import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen'; import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen'; import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen'; import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen'; import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen';
@@ -325,7 +325,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/> />
<Tab.Screen <Tab.Screen
name="Contacts" name="Contacts"
component={ContactsScreen} component={ContactsTab}
options={{ options={{
title: tabLabels.contacts, title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />, tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
@@ -19,6 +19,8 @@ interface ContactListRowProps {
onPress?: () => void; onPress?: () => void;
onLongPress?: () => void; onLongPress?: () => void;
selected?: boolean; selected?: boolean;
/** The contact open in the detail pane of a split view. */
active?: boolean;
selectionMode?: boolean; selectionMode?: boolean;
secondaryText?: string; secondaryText?: string;
} }
@@ -28,6 +30,7 @@ export default function ContactListRow({
onPress, onPress,
onLongPress, onLongPress,
selected, selected,
active = false,
selectionMode, selectionMode,
secondaryText, secondaryText,
}: ContactListRowProps) { }: ContactListRowProps) {
@@ -52,6 +55,7 @@ export default function ContactListRow({
style={({ pressed }) => [ style={({ pressed }) => [
styles.row, styles.row,
pressed && styles.rowPressed, pressed && styles.rowPressed,
active && styles.rowActive,
selected && styles.rowSelected, selected && styles.rowSelected,
]} ]}
> >
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
}, },
rowPressed: { backgroundColor: c.surfaceHover }, rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg }, rowSelected: { backgroundColor: c.primaryBg },
// Same as the open message in the mail list (webmail bg-selection + bar).
rowActive: {
backgroundColor: c.selection,
borderLeftWidth: 2,
borderLeftColor: c.primary,
paddingLeft: spacing.lg - 2,
},
checkbox: { checkbox: {
width: 22, width: 22,
height: 22, height: 22,
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { NavigationProvider, type NavigationProp, type ParamListBase } from '@react-navigation/native';
/** True inside the detail pane of a split view. */
const PaneContext = React.createContext(false);
/** A screen in a split view's detail pane has no back button. */
export function useInPane(): boolean {
return React.useContext(PaneContext);
}
/** Routes a pane shows itself instead of pushing them on the stack. */
export type PaneTarget = { name: string; params?: object };
/**
* Runs a stack screen inside a split view's detail pane. The screen's own
* `useNavigation()` / `useRoute()` get the pane: `goBack` closes it, the
* routes `onOpen` accepts open in it, everything else goes to the stack.
*/
export function PaneScreen({
navigation,
name,
params,
onOpen,
onClose,
children,
}: {
navigation: NavigationProp<ParamListBase>;
name: string;
params: object;
/** Return true when the pane shows this route itself. */
onOpen: (target: PaneTarget) => boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const route = React.useMemo(() => ({ key: `pane-${name}`, name, params }), [name, params]);
const paneNavigation = React.useMemo(() => {
const open = (method: 'navigate' | 'push' | 'replace') => (...args: unknown[]) => {
const [target, targetParams] = args as [string, object | undefined];
if (typeof target === 'string' && onOpen({ name: target, params: targetParams })) return;
// `push` and `replace` only exist on a stack; from a tab they become a
// navigate, which reaches the root stack all the same.
const real = navigation as unknown as Record<string, ((...a: unknown[]) => void) | undefined>;
(real[method] ?? real.navigate)!(...args);
};
return {
...navigation,
goBack: onClose,
navigate: open('navigate'),
push: open('push'),
replace: open('replace'),
canGoBack: () => true,
} as unknown as NavigationProp<ParamListBase>;
}, [navigation, onOpen, onClose]);
return (
<PaneContext.Provider value>
<NavigationProvider route={route} navigation={paneNavigation}>
{children}
</NavigationProvider>
</PaneContext.Provider>
);
}
+17 -11
View File
@@ -6,6 +6,7 @@ import {
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useInPane } from '../navigation/pane';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { import {
@@ -132,6 +133,7 @@ export default function ContactDetailScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const route = useRoute<Route>(); const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const inPane = useInPane();
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));
@@ -360,16 +362,19 @@ 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, inPane && styles.headerInPane]}>
<Pressable {/* In a split view's pane the list is right next to it: no back. */}
onPress={() => navigation.goBack()} {!inPane && (
style={styles.headerBtn} <Pressable
hitSlop={8} onPress={() => navigation.goBack()}
accessibilityRole="button" style={styles.headerBtn}
accessibilityLabel={t('common.back', 'Back')} hitSlop={8}
> accessibilityRole="button"
<ArrowLeft size={22} color={c.text} /> accessibilityLabel={t('common.back', 'Back')}
</Pressable> >
<ArrowLeft size={22} color={c.text} />
</Pressable>
)}
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')} {isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
</Text> </Text>
@@ -911,7 +916,8 @@ function makeStyles(c: ThemePalette) {
container: { flex: 1, backgroundColor: c.background }, container: { flex: 1, backgroundColor: c.background },
scrollContent: { paddingBottom: spacing.xxxl * 2 }, scrollContent: { paddingBottom: spacing.xxxl * 2 },
header: { headerInPane: { paddingLeft: spacing.lg },
header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
+12 -2
View File
@@ -101,7 +101,14 @@ function categoryTitle(
} }
} }
export default function ContactsScreen() { interface ContactsScreenProps {
/** Split view: open the contact in the detail pane instead of the stack. */
onOpenContact?: (contact: ContactCard) => void;
/** The contact open in the detail pane. */
activeContactId?: string | null;
}
export default function ContactsScreen({ onOpenContact, activeContactId = null }: ContactsScreenProps = {}) {
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>();
@@ -210,7 +217,9 @@ export default function ContactsScreen() {
toggleSelect(c.id); toggleSelect(c.id);
return; return;
} }
if (isGroup(c)) { if (onOpenContact) {
onOpenContact(c);
} else if (isGroup(c)) {
navigation.navigate('GroupDetail', { groupId: c.id }); navigation.navigate('GroupDetail', { groupId: c.id });
} else { } else {
navigation.navigate('ContactDetail', { contactId: c.id }); navigation.navigate('ContactDetail', { contactId: c.id });
@@ -541,6 +550,7 @@ export default function ContactsScreen() {
onPress={() => handleRowPress(item)} onPress={() => handleRowPress(item)}
onLongPress={() => handleLongPress(item)} onLongPress={() => handleLongPress(item)}
selected={selection.has(item.id)} selected={selection.has(item.id)}
active={item.id === activeContactId}
selectionMode={selectionMode} selectionMode={selectionMode}
/> />
)} )}
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { useNavigation, type NavigationProp, type ParamListBase } from '@react-navigation/native';
import { BookUser } from 'lucide-react-native';
import ContactsScreen from './ContactsScreen';
import ContactDetailScreen from './ContactDetailScreen';
import GroupDetailScreen from './GroupDetailScreen';
import type { ContactCard } from '../api/types';
import { isGroup } from '../lib/contact-utils';
import { listPaneWidth, useLayout } from '../lib/use-layout';
import { PaneScreen, type PaneTarget } from '../navigation/pane';
import { useLocaleStore } from '../stores/locale-store';
import { useColors } from '../theme/colors';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
type Open =
| { name: 'ContactDetail'; params: { contactId: string } }
| { name: 'GroupDetail'; params: { groupId: string } };
function openFor(contact: ContactCard): Open {
return isGroup(contact)
? { name: 'GroupDetail', params: { groupId: contact.id } }
: { name: 'ContactDetail', params: { contactId: contact.id } };
}
/**
* The Contacts tab: the list on a phone; list and contact side by side on a
* wide window, where the contact (or group) opens in the right pane.
*/
export default function ContactsTab() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NavigationProp<ParamListBase>>();
const { isSplit } = useLayout();
const { width } = useWindowDimensions();
const [open, setOpen] = React.useState<Open | null>(null);
// Narrowed with a contact open: hand it to the stack.
const openRef = React.useRef(open);
openRef.current = open;
React.useEffect(() => {
if (isSplit || !openRef.current) return;
navigation.navigate(openRef.current.name, openRef.current.params);
setOpen(null);
}, [isSplit, navigation]);
const onOpen = React.useCallback((target: PaneTarget) => {
if (target.name === 'ContactDetail' || target.name === 'GroupDetail') {
setOpen(target as Open);
return true;
}
return false;
}, []);
const onClose = React.useCallback(() => setOpen(null), []);
if (!isSplit) return <ContactsScreen />;
const activeId = open ? ('contactId' in open.params ? open.params.contactId : open.params.groupId) : null;
return (
<View style={styles.row}>
<View style={[styles.listPane, { width: listPaneWidth(width) }]}>
<ContactsScreen onOpenContact={(contact) => setOpen(openFor(contact))} activeContactId={activeId} />
</View>
<View style={styles.detailPane}>
{open ? (
<PaneScreen
key={activeId ?? undefined}
navigation={navigation}
name={open.name}
params={open.params}
onOpen={onOpen}
onClose={onClose}
>
{open.name === 'ContactDetail' ? <ContactDetailScreen /> : <GroupDetailScreen />}
</PaneScreen>
) : (
<View style={styles.empty}>
<BookUser size={40} color={c.textMuted} strokeWidth={1.5} />
<Text style={styles.emptyText}>{t('contacts.detail.no_contact_selected', 'Select a contact to view details')}</Text>
</View>
)}
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: { flex: 1, flexDirection: 'row', backgroundColor: c.background },
listPane: { borderRightWidth: 1, borderRightColor: c.border },
detailPane: { flex: 1 },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.xl },
emptyText: { ...typography.body, color: c.textMuted, textAlign: 'center' },
});
}
+16 -10
View File
@@ -3,6 +3,7 @@ import {
View, Text, StyleSheet, Pressable, FlatList, Alert, View, Text, StyleSheet, Pressable, FlatList, Alert,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useInPane } from '../navigation/pane';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { import {
@@ -35,6 +36,7 @@ export default function GroupDetailScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const route = useRoute<Route>(); const route = useRoute<Route>();
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const inPane = useInPane();
const { groupId } = route.params; const { groupId } = route.params;
const allContacts = useContactsStore((s) => s.contacts); const allContacts = useContactsStore((s) => s.contacts);
@@ -132,16 +134,19 @@ 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, inPane && styles.headerInPane]}>
<Pressable {/* In a split view's pane the list is right next to it: no back. */}
onPress={() => navigation.goBack()} {!inPane && (
style={styles.headerBtn} <Pressable
hitSlop={8} onPress={() => navigation.goBack()}
accessibilityRole="button" style={styles.headerBtn}
accessibilityLabel={t('common.back', 'Back')} hitSlop={8}
> accessibilityRole="button"
<ArrowLeft size={22} color={c.text} /> accessibilityLabel={t('common.back', 'Back')}
</Pressable> >
<ArrowLeft size={22} color={c.text} />
</Pressable>
)}
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text> <Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<Pressable <Pressable
@@ -263,6 +268,7 @@ export default function GroupDetailScreen() {
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
return StyleSheet.create({ return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background }, container: { flex: 1, backgroundColor: c.background },
headerInPane: { paddingLeft: spacing.lg },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',