From 64a62b6306b6d753d22715baca2e2468c1e04c71 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 15 Oct 2026 02:06:50 +0200
Subject: [PATCH] feat: show contacts as list and details side by side on
tablets
---
App.tsx | 4 +-
src/components/contacts/ContactListRow.tsx | 11 +++
src/navigation/pane.tsx | 62 ++++++++++++++
src/screens/ContactDetailScreen.tsx | 28 ++++---
src/screens/ContactsScreen.tsx | 14 +++-
src/screens/ContactsTab.tsx | 97 ++++++++++++++++++++++
src/screens/GroupDetailScreen.tsx | 26 +++---
7 files changed, 217 insertions(+), 25 deletions(-)
create mode 100644 src/navigation/pane.tsx
create mode 100644 src/screens/ContactsTab.tsx
diff --git a/App.tsx b/App.tsx
index 92176989..3b7dc402 100644
--- a/App.tsx
+++ b/App.tsx
@@ -46,7 +46,7 @@ import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
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 ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
@@ -325,7 +325,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps
,
diff --git a/src/components/contacts/ContactListRow.tsx b/src/components/contacts/ContactListRow.tsx
index b1e7853e..72e878d9 100644
--- a/src/components/contacts/ContactListRow.tsx
+++ b/src/components/contacts/ContactListRow.tsx
@@ -19,6 +19,8 @@ interface ContactListRowProps {
onPress?: () => void;
onLongPress?: () => void;
selected?: boolean;
+ /** The contact open in the detail pane of a split view. */
+ active?: boolean;
selectionMode?: boolean;
secondaryText?: string;
}
@@ -28,6 +30,7 @@ export default function ContactListRow({
onPress,
onLongPress,
selected,
+ active = false,
selectionMode,
secondaryText,
}: ContactListRowProps) {
@@ -52,6 +55,7 @@ export default function ContactListRow({
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
+ active && styles.rowActive,
selected && styles.rowSelected,
]}
>
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
},
rowPressed: { backgroundColor: c.surfaceHover },
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: {
width: 22,
height: 22,
diff --git a/src/navigation/pane.tsx b/src/navigation/pane.tsx
new file mode 100644
index 00000000..5ff21676
--- /dev/null
+++ b/src/navigation/pane.tsx
@@ -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;
+ 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 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;
+ }, [navigation, onOpen, onClose]);
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx
index 6d9f7ce0..f72230e1 100644
--- a/src/screens/ContactDetailScreen.tsx
+++ b/src/screens/ContactDetailScreen.tsx
@@ -6,6 +6,7 @@ import {
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { useInPane } from '../navigation/pane';
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
@@ -132,6 +133,7 @@ export default function ContactDetailScreen() {
const navigation = useNavigation