From 07a18595aa06a352fa311de318b60292d54f6196 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 26 Apr 2026 20:49:17 +0200
Subject: [PATCH] Refactor SettingsScreen: Consolidate settings tabs, improve
layout, and enhance user experience
---
App.tsx | 6 +-
src/components/settings/AboutDataSettings.tsx | 2 +-
src/components/settings/ReadingSettings.tsx | 2 +-
src/screens/CalendarScreen.tsx | 617 ++++++++++++++----
src/screens/CalendarScreenNew.tsx | 546 ----------------
src/screens/ContactsScreen.tsx | 530 +++++++++++++--
src/screens/ContactsScreenNew.tsx | 500 --------------
src/screens/EmailScreen.tsx | 106 ---
src/screens/SettingsScreen.tsx | 459 ++++++++++---
src/screens/SettingsScreenNew.tsx | 250 -------
src/screens/SettingsScreenNew2.tsx | 368 -----------
11 files changed, 1316 insertions(+), 2070 deletions(-)
delete mode 100644 src/screens/CalendarScreenNew.tsx
delete mode 100644 src/screens/ContactsScreenNew.tsx
delete mode 100644 src/screens/EmailScreen.tsx
delete mode 100644 src/screens/SettingsScreenNew.tsx
delete mode 100644 src/screens/SettingsScreenNew2.tsx
diff --git a/App.tsx b/App.tsx
index 8640aefb..855921f1 100644
--- a/App.tsx
+++ b/App.tsx
@@ -22,12 +22,12 @@ import EmailThreadScreen from './src/screens/EmailThreadScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import FilesScreen from './src/screens/FilesScreen';
-import CalendarScreen from './src/screens/CalendarScreenNew';
-import ContactsScreen from './src/screens/ContactsScreenNew';
+import CalendarScreen from './src/screens/CalendarScreen';
+import ContactsScreen from './src/screens/ContactsScreen';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
-import SettingsScreen from './src/screens/SettingsScreenNew2';
+import SettingsScreen from './src/screens/SettingsScreen';
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
diff --git a/src/components/settings/AboutDataSettings.tsx b/src/components/settings/AboutDataSettings.tsx
index cee60ed7..5b70bfdd 100644
--- a/src/components/settings/AboutDataSettings.tsx
+++ b/src/components/settings/AboutDataSettings.tsx
@@ -17,7 +17,7 @@ const DEBUG_CATEGORIES = [
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
-export function AdvancedSettings() {
+export function AboutDataSettings() {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx
index 1c591cb4..ce2a0ece 100644
--- a/src/components/settings/ReadingSettings.tsx
+++ b/src/components/settings/ReadingSettings.tsx
@@ -65,7 +65,7 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
);
}
-export function EmailSettings() {
+export function ReadingSettings() {
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index 51d3ed3c..af2a363c 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -1,165 +1,546 @@
import React from 'react';
-import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
-import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react-native';
-import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isToday, addMonths, subMonths } from 'date-fns';
-import { colors } from '../theme/colors';
+import {
+ View,
+ Text,
+ StyleSheet,
+ Pressable,
+ ActivityIndicator,
+ ScrollView,
+ RefreshControl,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ ChevronLeft,
+ ChevronRight,
+ Plus,
+ CalendarDays,
+ LayoutGrid,
+ List as ListIcon,
+ Menu,
+} from 'lucide-react-native';
+import {
+ format,
+ startOfMonth,
+ endOfMonth,
+ startOfWeek,
+ endOfWeek,
+ addDays,
+ isToday,
+ addMonths,
+ subMonths,
+ addWeeks,
+ subWeeks,
+} from 'date-fns';
+import { colors, spacing, radius, typography } from '../theme/tokens';
+import { Button } from '../components';
+import { useCalendarStore } from '../stores/calendar-store';
+import { MonthView } from '../components/calendar/MonthView';
+import { WeekView } from '../components/calendar/WeekView';
+import { AgendaView } from '../components/calendar/AgendaView';
+import { EventCard } from '../components/calendar/EventCard';
+import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
+import { EventModal } from '../components/calendar/EventModal';
+import {
+ RecurrenceScopeDialog,
+ type RecurrenceEditScope,
+} from '../components/calendar/RecurrenceScopeDialog';
+import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
+import {
+ buildEventDayIndex,
+ eventsOnDayFromIndex,
+ type EventDayIndex,
+} from '../lib/calendar-utils';
+import type { Calendar, CalendarEvent } from '../api/types';
-const MOCK_EVENTS = [
- { id: '1', title: 'Team Standup', time: '09:00', color: '#3b82f6' },
- { id: '2', title: 'Design Review', time: '11:00', color: '#8b5cf6' },
- { id: '3', title: 'Lunch with Sarah', time: '12:30', color: '#22c55e' },
- { id: '4', title: 'Sprint Planning', time: '14:00', color: '#f59e0b' },
-];
+type ViewMode = 'month' | 'week' | 'agenda';
+type PendingAction =
+ | { kind: 'edit'; event: CalendarEvent }
+ | { kind: 'delete'; event: CalendarEvent }
+ | null;
-const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+const AGENDA_DAYS = 30;
+const RANGE_BUFFER_DAYS = 14;
-function MonthGrid({ currentDate }: { currentDate: Date }) {
- const monthStart = startOfMonth(currentDate);
- const monthEnd = endOfMonth(currentDate);
- const calStart = startOfWeek(monthStart);
- const calEnd = endOfWeek(monthEnd);
-
- const days: Date[] = [];
- let day = calStart;
- while (day <= calEnd) {
- days.push(day);
- day = addDays(day, 1);
+function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
+ if (viewMode === 'month') {
+ const start = startOfWeek(startOfMonth(currentDate));
+ const end = endOfWeek(endOfMonth(currentDate));
+ return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
+ if (viewMode === 'week') {
+ const start = startOfWeek(currentDate);
+ const end = endOfWeek(currentDate);
+ return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
+ }
+ return {
+ after: addDays(currentDate, -1),
+ before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
+ };
+}
- return (
-
-
- {WEEKDAYS.map((wd) => (
- {wd}
- ))}
-
-
- {days.map((d, i) => {
- const sameMonth = isSameMonth(d, currentDate);
- const today = isToday(d);
- return (
-
-
-
- {format(d, 'd')}
-
-
-
- );
- })}
-
-
- );
+function headerTitle(viewMode: ViewMode, currentDate: Date): string {
+ if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
+ if (viewMode === 'week') {
+ const start = startOfWeek(currentDate);
+ const end = endOfWeek(currentDate);
+ if (start.getMonth() === end.getMonth()) {
+ return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
+ }
+ return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
+ }
+ return format(currentDate, 'MMMM yyyy');
}
export default function CalendarScreen() {
const [currentDate, setCurrentDate] = React.useState(new Date());
+ const [selectedDate, setSelectedDate] = React.useState(new Date());
+ const [viewMode, setViewMode] = React.useState('month');
+
+ const [detailEvent, setDetailEvent] = React.useState(null);
+ const [modalEvent, setModalEvent] = React.useState(null);
+ const [modalDate, setModalDate] = React.useState(undefined);
+ const [modalVisible, setModalVisible] = React.useState(false);
+ const [pendingAction, setPendingAction] = React.useState(null);
+ const [sidebarVisible, setSidebarVisible] = React.useState(false);
+ const [refreshing, setRefreshing] = React.useState(false);
+
+ const hydrate = useCalendarStore((s) => s.hydrate);
+ const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
+ const ensureRange = useCalendarStore((s) => s.ensureRange);
+ const refresh = useCalendarStore((s) => s.refresh);
+ const createEvent = useCalendarStore((s) => s.createEvent);
+ const updateEvent = useCalendarStore((s) => s.updateEvent);
+ const deleteEvent = useCalendarStore((s) => s.deleteEvent);
+ const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
+ const allCalendars = useCalendarStore((s) => s.calendars);
+ const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
+ const loading = useCalendarStore((s) => s.loading);
+ const error = useCalendarStore((s) => s.error);
+ const allEvents = useCalendarStore((s) => s.events);
+
+ const calendars = React.useMemo(
+ () => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
+ [allCalendars, hiddenCalendarIds],
+ );
+ const events = React.useMemo(() => {
+ if (hiddenCalendarIds.length === 0) return allEvents;
+ const hidden = new Set(hiddenCalendarIds);
+ return allEvents.filter((e) => {
+ const ids = Object.keys(e.calendarIds || {});
+ if (ids.length === 0) return true;
+ return ids.some((id) => !hidden.has(id));
+ });
+ }, [allEvents, hiddenCalendarIds]);
+ // Pre-index events by day once. Child views do O(1) map lookups per cell
+ // instead of re-filtering the full event list with parseISO per day.
+ const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
+
+ React.useEffect(() => {
+ void hydrate();
+ void fetchCalendarsAction();
+ }, [hydrate, fetchCalendarsAction]);
+
+ React.useEffect(() => {
+ const { after, before } = rangeForView(viewMode, currentDate);
+ void ensureRange(after.toISOString(), before.toISOString());
+ }, [viewMode, currentDate, ensureRange]);
+
+ const goPrev = React.useCallback(() => {
+ setCurrentDate((d) => {
+ if (viewMode === 'month') return subMonths(d, 1);
+ if (viewMode === 'week') return subWeeks(d, 1);
+ return addDays(d, -AGENDA_DAYS);
+ });
+ }, [viewMode]);
+
+ const goNext = React.useCallback(() => {
+ setCurrentDate((d) => {
+ if (viewMode === 'month') return addMonths(d, 1);
+ if (viewMode === 'week') return addWeeks(d, 1);
+ return addDays(d, AGENDA_DAYS);
+ });
+ }, [viewMode]);
+
+ const goToday = React.useCallback(() => {
+ const today = new Date();
+ setCurrentDate(today);
+ setSelectedDate(today);
+ }, []);
+
+ const handleSelectDate = React.useCallback(
+ (date: Date) => {
+ setSelectedDate(date);
+ if (viewMode === 'week') setCurrentDate(date);
+ },
+ [viewMode],
+ );
+
+ const openCreate = React.useCallback((date?: Date) => {
+ setModalEvent(null);
+ setModalDate(date);
+ setModalVisible(true);
+ }, []);
+
+ const openEditDirect = React.useCallback((event: CalendarEvent) => {
+ setModalEvent(event);
+ setModalDate(undefined);
+ setModalVisible(true);
+ }, []);
+
+ const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
+ setDetailEvent(null);
+ if (event.recurrenceRules?.length || event.originalId) {
+ setPendingAction({ kind: 'edit', event });
+ } else {
+ openEditDirect(event);
+ }
+ }, [openEditDirect]);
+
+ const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
+ setDetailEvent(null);
+ if (event.recurrenceRules?.length || event.originalId) {
+ setPendingAction({ kind: 'delete', event });
+ } else {
+ void deleteEvent(event.id);
+ }
+ }, [deleteEvent]);
+
+ const handleScopeSelect = React.useCallback(
+ async (scope: RecurrenceEditScope) => {
+ const action = pendingAction;
+ setPendingAction(null);
+ if (!action) return;
+ // For the first pass, treat all scopes as "all" - proper override
+ // handling lives in the store's update/delete path against originalId.
+ // TODO: implement "this only" via recurrenceOverrides write, and
+ // "this and following" via excludedRecurrenceRules + new master.
+ if (action.kind === 'edit') {
+ openEditDirect(action.event);
+ } else {
+ await deleteEvent(action.event.id);
+ }
+ },
+ [pendingAction, openEditDirect, deleteEvent],
+ );
+
+ const handleSave = React.useCallback(
+ async (data: Partial, calendarId: string) => {
+ if (modalEvent) {
+ await updateEvent(modalEvent.id, data);
+ } else {
+ await createEvent(data, calendarId);
+ }
+ },
+ [modalEvent, createEvent, updateEvent],
+ );
+
+ const handleDeleteFromModal = React.useCallback(
+ async (event: CalendarEvent) => {
+ setModalVisible(false);
+ await deleteEvent(event.id);
+ },
+ [deleteEvent],
+ );
+
+ const onRefresh = React.useCallback(async () => {
+ setRefreshing(true);
+ try {
+ await refresh();
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refresh]);
+
+ const isSelectedToday = isToday(selectedDate);
return (
-
+
-
- Calendar
+ setSidebarVisible(true)}
+ hitSlop={8}
+ style={styles.headerBtn}
+ >
+
+
+
+ {headerTitle(viewMode, currentDate)}
+
+ {isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
+
+
+
+
+ {(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
+ const Icon =
+ mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
+ const active = viewMode === mode;
+ return (
+ setViewMode(mode)}
+ >
+
+
+ );
+ })}
+
+ openCreate(selectedDate)}>
+
+
+
-
- setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
+
+
- {format(currentDate, 'MMMM yyyy')}
- setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
+
+
-
+ {error && (
+
+ {error}
+
+ )}
-
- Today's Events
- {MOCK_EVENTS.map((event) => (
-
-
-
- {event.title}
- {event.time}
+
+ {viewMode === 'month' && (
+ openCreate(date)}
+ />
+ )}
+ {viewMode === 'week' && (
+ openCreate(date)}
+ />
+ )}
+ {viewMode === 'agenda' && (
+
+ )}
+
+ {viewMode === 'month' && (
+
+
+
+ {isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
+
+ {loading && }
-
- ))}
+
+
+ )}
+
+ setDetailEvent(null)}
+ onEdit={handleEditFromDetail}
+ onDelete={handleDeleteFromDetail}
+ />
+
+ setModalVisible(false)}
+ />
+
+ setPendingAction(null)}
+ />
+
+ setSidebarVisible(false)}
+ />
+
+ );
+}
+
+function DayEventList({
+ date,
+ eventsByDay,
+ calendars,
+ onSelectEvent,
+ refreshing,
+ onRefresh,
+}: {
+ date: Date;
+ eventsByDay: EventDayIndex;
+ calendars: Calendar[];
+ onSelectEvent?: (event: CalendarEvent) => void;
+ refreshing: boolean;
+ onRefresh: () => void;
+}) {
+ const dayEvents = React.useMemo(
+ () => eventsOnDayFromIndex(eventsByDay, date),
+ [eventsByDay, date],
+ );
+ if (dayEvents.length === 0) {
+ return (
+
+ }
+ >
+
+ No events
+ Tap + to create one
+
+ );
+ }
+ return (
+
+ }
+ >
+ {dayEvents.map((event) => (
+
+ ))}
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
+
header: {
flexDirection: 'row',
alignItems: 'center',
- paddingHorizontal: 16,
- paddingVertical: 12,
- borderBottomWidth: 1,
- borderBottomColor: colors.border,
- gap: 8,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ gap: spacing.sm,
},
- headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
- monthNav: {
- flexDirection: 'row',
+ headerBtn: {
+ width: 36,
+ height: 36,
alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 16,
- paddingVertical: 12,
+ justifyContent: 'center',
+ borderRadius: radius.full,
},
- navBtn: { padding: 8 },
- monthLabel: { fontSize: 17, fontWeight: '600', color: colors.text },
- weekdayRow: {
+ headerLeft: { flex: 1 },
+ headerTitle: { ...typography.h3, color: colors.text },
+ headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
+ headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ viewToggle: {
flexDirection: 'row',
- paddingHorizontal: 8,
- marginBottom: 4,
+ backgroundColor: colors.surface,
+ borderRadius: radius.md,
+ padding: 2,
},
- weekdayLabel: {
- flex: 1,
- textAlign: 'center',
- fontSize: 12,
- fontWeight: '600',
- color: colors.textMuted,
- },
- daysGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- paddingHorizontal: 8,
- },
- dayCell: {
- width: '14.28%',
- alignItems: 'center',
- paddingVertical: 6,
- },
- dayNumber: {
+ viewToggleBtn: {
width: 32,
height: 32,
- borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.sm,
+ },
+ viewToggleBtnActive: { backgroundColor: colors.background },
+ fab: {
+ width: 36,
+ height: 36,
+ borderRadius: radius.full,
+ backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
- dayText: { fontSize: 14, color: colors.text },
- dayTextMuted: { color: colors.textMuted },
- todayCircle: { backgroundColor: colors.primary },
- todayText: { color: colors.textInverse, fontWeight: '700' },
- todaySection: { paddingHorizontal: 16, paddingTop: 20 },
- todaySectionTitle: { fontSize: 16, fontWeight: '600', color: colors.text, marginBottom: 12 },
- eventCard: {
+
+ nav: {
flexDirection: 'row',
alignItems: 'center',
- backgroundColor: colors.surface,
- borderRadius: 10,
- padding: 12,
- marginBottom: 8,
+ justifyContent: 'center',
+ paddingVertical: spacing.xs,
+ gap: spacing.lg,
},
- eventDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
- eventContent: { flex: 1 },
- eventTitle: { fontSize: 15, fontWeight: '500', color: colors.text },
- eventTime: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
+ navBtn: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.full,
+ },
+
+ errorBanner: {
+ backgroundColor: colors.errorBg,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ },
+ errorText: { ...typography.caption, color: colors.errorForeground },
+
+ content: { flex: 1 },
+
+ dayDetail: {
+ flex: 1,
+ borderTopWidth: 1,
+ borderTopColor: colors.border,
+ },
+ dayDetailHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ },
+ dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
+ dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
+
+ emptyState: {
+ alignItems: 'center',
+ paddingVertical: spacing.xl,
+ gap: spacing.xs,
+ flexGrow: 1,
+ },
+ emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
+ emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
diff --git a/src/screens/CalendarScreenNew.tsx b/src/screens/CalendarScreenNew.tsx
deleted file mode 100644
index af2a363c..00000000
--- a/src/screens/CalendarScreenNew.tsx
+++ /dev/null
@@ -1,546 +0,0 @@
-import React from 'react';
-import {
- View,
- Text,
- StyleSheet,
- Pressable,
- ActivityIndicator,
- ScrollView,
- RefreshControl,
-} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import {
- ChevronLeft,
- ChevronRight,
- Plus,
- CalendarDays,
- LayoutGrid,
- List as ListIcon,
- Menu,
-} from 'lucide-react-native';
-import {
- format,
- startOfMonth,
- endOfMonth,
- startOfWeek,
- endOfWeek,
- addDays,
- isToday,
- addMonths,
- subMonths,
- addWeeks,
- subWeeks,
-} from 'date-fns';
-import { colors, spacing, radius, typography } from '../theme/tokens';
-import { Button } from '../components';
-import { useCalendarStore } from '../stores/calendar-store';
-import { MonthView } from '../components/calendar/MonthView';
-import { WeekView } from '../components/calendar/WeekView';
-import { AgendaView } from '../components/calendar/AgendaView';
-import { EventCard } from '../components/calendar/EventCard';
-import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
-import { EventModal } from '../components/calendar/EventModal';
-import {
- RecurrenceScopeDialog,
- type RecurrenceEditScope,
-} from '../components/calendar/RecurrenceScopeDialog';
-import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
-import {
- buildEventDayIndex,
- eventsOnDayFromIndex,
- type EventDayIndex,
-} from '../lib/calendar-utils';
-import type { Calendar, CalendarEvent } from '../api/types';
-
-type ViewMode = 'month' | 'week' | 'agenda';
-type PendingAction =
- | { kind: 'edit'; event: CalendarEvent }
- | { kind: 'delete'; event: CalendarEvent }
- | null;
-
-const AGENDA_DAYS = 30;
-const RANGE_BUFFER_DAYS = 14;
-
-function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
- if (viewMode === 'month') {
- const start = startOfWeek(startOfMonth(currentDate));
- const end = endOfWeek(endOfMonth(currentDate));
- return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
- }
- if (viewMode === 'week') {
- const start = startOfWeek(currentDate);
- const end = endOfWeek(currentDate);
- return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
- }
- return {
- after: addDays(currentDate, -1),
- before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
- };
-}
-
-function headerTitle(viewMode: ViewMode, currentDate: Date): string {
- if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
- if (viewMode === 'week') {
- const start = startOfWeek(currentDate);
- const end = endOfWeek(currentDate);
- if (start.getMonth() === end.getMonth()) {
- return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
- }
- return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
- }
- return format(currentDate, 'MMMM yyyy');
-}
-
-export default function CalendarScreen() {
- const [currentDate, setCurrentDate] = React.useState(new Date());
- const [selectedDate, setSelectedDate] = React.useState(new Date());
- const [viewMode, setViewMode] = React.useState('month');
-
- const [detailEvent, setDetailEvent] = React.useState(null);
- const [modalEvent, setModalEvent] = React.useState(null);
- const [modalDate, setModalDate] = React.useState(undefined);
- const [modalVisible, setModalVisible] = React.useState(false);
- const [pendingAction, setPendingAction] = React.useState(null);
- const [sidebarVisible, setSidebarVisible] = React.useState(false);
- const [refreshing, setRefreshing] = React.useState(false);
-
- const hydrate = useCalendarStore((s) => s.hydrate);
- const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
- const ensureRange = useCalendarStore((s) => s.ensureRange);
- const refresh = useCalendarStore((s) => s.refresh);
- const createEvent = useCalendarStore((s) => s.createEvent);
- const updateEvent = useCalendarStore((s) => s.updateEvent);
- const deleteEvent = useCalendarStore((s) => s.deleteEvent);
- const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
- const allCalendars = useCalendarStore((s) => s.calendars);
- const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
- const loading = useCalendarStore((s) => s.loading);
- const error = useCalendarStore((s) => s.error);
- const allEvents = useCalendarStore((s) => s.events);
-
- const calendars = React.useMemo(
- () => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
- [allCalendars, hiddenCalendarIds],
- );
- const events = React.useMemo(() => {
- if (hiddenCalendarIds.length === 0) return allEvents;
- const hidden = new Set(hiddenCalendarIds);
- return allEvents.filter((e) => {
- const ids = Object.keys(e.calendarIds || {});
- if (ids.length === 0) return true;
- return ids.some((id) => !hidden.has(id));
- });
- }, [allEvents, hiddenCalendarIds]);
- // Pre-index events by day once. Child views do O(1) map lookups per cell
- // instead of re-filtering the full event list with parseISO per day.
- const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
-
- React.useEffect(() => {
- void hydrate();
- void fetchCalendarsAction();
- }, [hydrate, fetchCalendarsAction]);
-
- React.useEffect(() => {
- const { after, before } = rangeForView(viewMode, currentDate);
- void ensureRange(after.toISOString(), before.toISOString());
- }, [viewMode, currentDate, ensureRange]);
-
- const goPrev = React.useCallback(() => {
- setCurrentDate((d) => {
- if (viewMode === 'month') return subMonths(d, 1);
- if (viewMode === 'week') return subWeeks(d, 1);
- return addDays(d, -AGENDA_DAYS);
- });
- }, [viewMode]);
-
- const goNext = React.useCallback(() => {
- setCurrentDate((d) => {
- if (viewMode === 'month') return addMonths(d, 1);
- if (viewMode === 'week') return addWeeks(d, 1);
- return addDays(d, AGENDA_DAYS);
- });
- }, [viewMode]);
-
- const goToday = React.useCallback(() => {
- const today = new Date();
- setCurrentDate(today);
- setSelectedDate(today);
- }, []);
-
- const handleSelectDate = React.useCallback(
- (date: Date) => {
- setSelectedDate(date);
- if (viewMode === 'week') setCurrentDate(date);
- },
- [viewMode],
- );
-
- const openCreate = React.useCallback((date?: Date) => {
- setModalEvent(null);
- setModalDate(date);
- setModalVisible(true);
- }, []);
-
- const openEditDirect = React.useCallback((event: CalendarEvent) => {
- setModalEvent(event);
- setModalDate(undefined);
- setModalVisible(true);
- }, []);
-
- const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
- setDetailEvent(null);
- if (event.recurrenceRules?.length || event.originalId) {
- setPendingAction({ kind: 'edit', event });
- } else {
- openEditDirect(event);
- }
- }, [openEditDirect]);
-
- const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
- setDetailEvent(null);
- if (event.recurrenceRules?.length || event.originalId) {
- setPendingAction({ kind: 'delete', event });
- } else {
- void deleteEvent(event.id);
- }
- }, [deleteEvent]);
-
- const handleScopeSelect = React.useCallback(
- async (scope: RecurrenceEditScope) => {
- const action = pendingAction;
- setPendingAction(null);
- if (!action) return;
- // For the first pass, treat all scopes as "all" - proper override
- // handling lives in the store's update/delete path against originalId.
- // TODO: implement "this only" via recurrenceOverrides write, and
- // "this and following" via excludedRecurrenceRules + new master.
- if (action.kind === 'edit') {
- openEditDirect(action.event);
- } else {
- await deleteEvent(action.event.id);
- }
- },
- [pendingAction, openEditDirect, deleteEvent],
- );
-
- const handleSave = React.useCallback(
- async (data: Partial, calendarId: string) => {
- if (modalEvent) {
- await updateEvent(modalEvent.id, data);
- } else {
- await createEvent(data, calendarId);
- }
- },
- [modalEvent, createEvent, updateEvent],
- );
-
- const handleDeleteFromModal = React.useCallback(
- async (event: CalendarEvent) => {
- setModalVisible(false);
- await deleteEvent(event.id);
- },
- [deleteEvent],
- );
-
- const onRefresh = React.useCallback(async () => {
- setRefreshing(true);
- try {
- await refresh();
- } finally {
- setRefreshing(false);
- }
- }, [refresh]);
-
- const isSelectedToday = isToday(selectedDate);
-
- return (
-
-
- setSidebarVisible(true)}
- hitSlop={8}
- style={styles.headerBtn}
- >
-
-
-
- {headerTitle(viewMode, currentDate)}
-
- {isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
-
-
-
-
- {(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
- const Icon =
- mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
- const active = viewMode === mode;
- return (
- setViewMode(mode)}
- >
-
-
- );
- })}
-
- openCreate(selectedDate)}>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
- {viewMode === 'month' && (
- openCreate(date)}
- />
- )}
- {viewMode === 'week' && (
- openCreate(date)}
- />
- )}
- {viewMode === 'agenda' && (
-
- )}
-
- {viewMode === 'month' && (
-
-
-
- {isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
-
- {loading && }
-
-
-
- )}
-
-
- setDetailEvent(null)}
- onEdit={handleEditFromDetail}
- onDelete={handleDeleteFromDetail}
- />
-
- setModalVisible(false)}
- />
-
- setPendingAction(null)}
- />
-
- setSidebarVisible(false)}
- />
-
- );
-}
-
-function DayEventList({
- date,
- eventsByDay,
- calendars,
- onSelectEvent,
- refreshing,
- onRefresh,
-}: {
- date: Date;
- eventsByDay: EventDayIndex;
- calendars: Calendar[];
- onSelectEvent?: (event: CalendarEvent) => void;
- refreshing: boolean;
- onRefresh: () => void;
-}) {
- const dayEvents = React.useMemo(
- () => eventsOnDayFromIndex(eventsByDay, date),
- [eventsByDay, date],
- );
- if (dayEvents.length === 0) {
- return (
-
- }
- >
-
- No events
- Tap + to create one
-
- );
- }
- return (
-
- }
- >
- {dayEvents.map((event) => (
-
- ))}
-
- );
-}
-
-const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: colors.background },
-
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- gap: spacing.sm,
- },
- headerBtn: {
- width: 36,
- height: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radius.full,
- },
- headerLeft: { flex: 1 },
- headerTitle: { ...typography.h3, color: colors.text },
- headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
- headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
- viewToggle: {
- flexDirection: 'row',
- backgroundColor: colors.surface,
- borderRadius: radius.md,
- padding: 2,
- },
- viewToggleBtn: {
- width: 32,
- height: 32,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radius.sm,
- },
- viewToggleBtnActive: { backgroundColor: colors.background },
- fab: {
- width: 36,
- height: 36,
- borderRadius: radius.full,
- backgroundColor: colors.primary,
- alignItems: 'center',
- justifyContent: 'center',
- },
-
- nav: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: spacing.xs,
- gap: spacing.lg,
- },
- navBtn: {
- width: 36,
- height: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radius.full,
- },
-
- errorBanner: {
- backgroundColor: colors.errorBg,
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- },
- errorText: { ...typography.caption, color: colors.errorForeground },
-
- content: { flex: 1 },
-
- dayDetail: {
- flex: 1,
- borderTopWidth: 1,
- borderTopColor: colors.border,
- },
- dayDetailHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- },
- dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
- dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
-
- emptyState: {
- alignItems: 'center',
- paddingVertical: spacing.xl,
- gap: spacing.xs,
- flexGrow: 1,
- },
- emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
- emptySubtitle: { ...typography.caption, color: colors.textMuted },
-});
diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx
index b2eb44c3..6930bf35 100644
--- a/src/screens/ContactsScreen.tsx
+++ b/src/screens/ContactsScreen.tsx
@@ -1,110 +1,500 @@
import React from 'react';
-import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native';
-import { Users } from 'lucide-react-native';
-import { colors } from '../theme/colors';
+import {
+ View,
+ Text,
+ StyleSheet,
+ SectionList,
+ Pressable,
+ TextInput,
+ ActivityIndicator,
+ RefreshControl,
+ ScrollView,
+} from 'react-native';
+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,
+} from 'lucide-react-native';
+import type { RootStackParamList } from '../navigation/types';
+import type { ContactCard } from '../api/types';
+import {
+ useContactsStore,
+ sortContactsByDisplayName,
+ selectGroupMembers,
+ type ContactCategory,
+} from '../stores/contacts-store';
+import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
+import { ContactListRow } from '../components/contacts';
+import Dialog from '../components/Dialog';
+import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
+import { useSettingsStore } from '../stores/settings-store';
+import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
-const MOCK_CONTACTS = [
- { id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101' },
- { id: '2', name: 'Bob Smith', email: 'bob@example.com', phone: '+1 555-0102' },
- { id: '3', name: 'Carol Davis', email: 'carol@example.com', phone: '+1 555-0103' },
- { id: '4', name: 'David Wilson', email: 'david@example.com', phone: '+1 555-0104' },
- { id: '5', name: 'Emma Brown', email: 'emma@example.com', phone: '+1 555-0105' },
- { id: '6', name: 'Frank Miller', email: 'frank@example.com', phone: '+1 555-0106' },
- { id: '7', name: 'Grace Lee', email: 'grace@example.com', phone: '+1 555-0107' },
- { id: '8', name: 'Henry Taylor', email: 'henry@example.com', phone: '+1 555-0108' },
-];
+type Nav = NativeStackNavigationProp;
-function groupByLetter(contacts: typeof MOCK_CONTACTS) {
- const groups: Record = {};
- contacts.forEach((c) => {
- const letter = c.name.charAt(0).toUpperCase();
- if (!groups[letter]) groups[letter] = [];
- groups[letter].push(c);
- });
- return Object.keys(groups)
- .sort()
- .map((letter) => ({ title: letter, data: groups[letter] }));
+interface Section {
+ title: string;
+ data: ContactCard[];
}
-function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) {
- return (
- [styles.contactRow, pressed && styles.contactRowPressed]}>
-
- {item.name.charAt(0)}
-
-
- {item.name}
- {item.email}
-
-
- );
+function groupContacts(contacts: ContactCard[]): Section[] {
+ const sorted = sortContactsByDisplayName(contacts);
+ const groups: Record = {};
+ for (const c of sorted) {
+ const name = getContactDisplayName(c).trim();
+ const letter = (name[0] || '#').toUpperCase();
+ const key = /[A-Z]/.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);
+ })
+ .map((title) => ({ title, data: groups[title] }));
+}
+
+function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
+ switch (category.type) {
+ case 'all':
+ return 'All Contacts';
+ case 'addressBook':
+ return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
+ case 'group': {
+ const g = groups.find((c) => c.id === category.groupId);
+ return g ? getContactDisplayName(g) || 'Group' : 'Group';
+ }
+ case 'keyword':
+ return `#${category.keyword}`;
+ case 'uncategorized':
+ return 'Uncategorized';
+ default:
+ return 'Contacts';
+ }
}
export default function ContactsScreen() {
- const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []);
+ const navigation = useNavigation