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