mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Refactor SettingsScreen: Consolidate settings tabs, improve layout, and enhance user experience
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+499
-118
@@ -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 (
|
||||
<View>
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<Text key={wd} style={styles.weekdayLabel}>{wd}</Text>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((d, i) => {
|
||||
const sameMonth = isSameMonth(d, currentDate);
|
||||
const today = isToday(d);
|
||||
return (
|
||||
<View key={i} style={styles.dayCell}>
|
||||
<View style={[styles.dayNumber, today && styles.todayCircle]}>
|
||||
<Text style={[
|
||||
styles.dayText,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.todayText,
|
||||
]}>
|
||||
{format(d, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
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<ViewMode>('month');
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(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<CalendarEvent>, 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 (
|
||||
<ScrollView style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<CalendarDays size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Calendar</Text>
|
||||
<Pressable
|
||||
onPress={() => setSidebarVisible(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode}
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.monthNav}>
|
||||
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
|
||||
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<MonthGrid currentDate={currentDate} />
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.todaySection}>
|
||||
<Text style={styles.todaySectionTitle}>Today's Events</Text>
|
||||
{MOCK_EVENTS.map((event) => (
|
||||
<Pressable key={event.id} style={styles.eventCard}>
|
||||
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
|
||||
<View style={styles.eventContent}>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventTime}>{event.time}</Text>
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
fromDate={currentDate}
|
||||
daysAhead={AGENDA_DAYS}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
/>
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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<ViewMode>('month');
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(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<CalendarEvent>, 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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSidebarVisible(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode}
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
fromDate={currentDate}
|
||||
daysAhead={AGENDA_DAYS}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
</View>
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
/>
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
+460
-70
@@ -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<RootStackParamList>;
|
||||
|
||||
function groupByLetter(contacts: typeof MOCK_CONTACTS) {
|
||||
const groups: Record<string, typeof MOCK_CONTACTS> = {};
|
||||
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 (
|
||||
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
|
||||
</View>
|
||||
<View style={styles.contactInfo}>
|
||||
<Text style={styles.contactName}>{item.name}</Text>
|
||||
<Text style={styles.contactEmail}>{item.email}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
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<Nav>();
|
||||
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 hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = 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':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
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 title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// 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: '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: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Users size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Contacts</Text>
|
||||
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.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}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.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]}
|
||||
>
|
||||
<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 }) => <ContactRow item={item} />}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
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={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
|
||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
||||
listContent: { paddingBottom: 20 },
|
||||
sectionHeader: {
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
||||
contactRow: {
|
||||
headerActions: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
contactRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
avatar: {
|
||||
headerIconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse },
|
||||
contactInfo: { flex: 1 },
|
||||
contactName: { fontSize: 15, fontWeight: '500', color: colors.text },
|
||||
contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.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: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
data: ContactCard[];
|
||||
}
|
||||
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
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 navigation = useNavigation<Nav>();
|
||||
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 hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = 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':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
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 title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// 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: '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: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
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}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.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}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.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]}
|
||||
>
|
||||
<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={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.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: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.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: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable } from 'react-native';
|
||||
import { Mail, Star } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
const MOCK_EMAILS = [
|
||||
{ id: '1', from: 'Alice Johnson', subject: 'Project update for Q2', preview: 'Hi, I wanted to share the latest progress on our Q2 deliverables...', date: '10:30 AM', unread: true, starred: true },
|
||||
{ id: '2', from: 'Bob Smith', subject: 'Re: Meeting tomorrow', preview: 'Sounds good! I\'ll bring the presentation slides...', date: '9:15 AM', unread: true, starred: false },
|
||||
{ id: '3', from: 'Carol Davis', subject: 'Invoice #1234', preview: 'Please find attached the invoice for March services...', date: 'Yesterday', unread: false, starred: false },
|
||||
{ id: '4', from: 'David Wilson', subject: 'Welcome to the team!', preview: 'We\'re excited to have you on board. Here are some resources...', date: 'Yesterday', unread: false, starred: true },
|
||||
{ id: '5', from: 'Emma Brown', subject: 'Vacation request', preview: 'I\'d like to request time off from April 15-22...', date: 'Mar 27', unread: false, starred: false },
|
||||
{ id: '6', from: 'Frank Miller', subject: 'Security alert', preview: 'We detected a new sign-in to your account from...', date: 'Mar 26', unread: false, starred: false },
|
||||
{ id: '7', from: 'Grace Lee', subject: 'Newsletter: March Highlights', preview: 'Here\'s what happened this month in our community...', date: 'Mar 25', unread: false, starred: false },
|
||||
{ id: '8', from: 'Henry Taylor', subject: 'Re: Code review', preview: 'Great work on the refactor! I left a few comments...', date: 'Mar 24', unread: false, starred: false },
|
||||
];
|
||||
|
||||
function EmailRow({ item }: { item: typeof MOCK_EMAILS[0] }) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}>
|
||||
<View style={styles.emailLeft}>
|
||||
<View style={[styles.avatar, { backgroundColor: item.unread ? colors.primary : colors.surfaceHover }]}>
|
||||
<Text style={[styles.avatarText, { color: item.unread ? colors.textInverse : colors.textSecondary }]}>
|
||||
{item.from.charAt(0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.emailContent}>
|
||||
<View style={styles.emailHeader}>
|
||||
<Text style={[styles.emailFrom, item.unread && styles.emailUnread]} numberOfLines={1}>
|
||||
{item.from}
|
||||
</Text>
|
||||
<Text style={styles.emailDate}>{item.date}</Text>
|
||||
</View>
|
||||
<Text style={[styles.emailSubject, item.unread && styles.emailUnread]} numberOfLines={1}>
|
||||
{item.subject}
|
||||
</Text>
|
||||
<Text style={styles.emailPreview} numberOfLines={1}>
|
||||
{item.preview}
|
||||
</Text>
|
||||
</View>
|
||||
{item.starred && (
|
||||
<Star size={16} color={colors.starred} fill={colors.starred} style={styles.starIcon} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Mail size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Inbox</Text>
|
||||
<Text style={styles.headerCount}>2 unread</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={MOCK_EMAILS}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <EmailRow item={item} />}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
|
||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
||||
listContent: { paddingBottom: 20 },
|
||||
emailRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
emailRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
emailLeft: { marginRight: 12 },
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: { fontSize: 16, fontWeight: '600' },
|
||||
emailContent: { flex: 1 },
|
||||
emailHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 },
|
||||
emailFrom: { fontSize: 15, color: colors.text, flex: 1, marginRight: 8 },
|
||||
emailDate: { fontSize: 12, color: colors.textMuted },
|
||||
emailSubject: { fontSize: 14, color: colors.text, marginBottom: 2 },
|
||||
emailPreview: { fontSize: 13, color: colors.textSecondary },
|
||||
emailUnread: { fontWeight: '700' },
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
starIcon: { marginLeft: 8 },
|
||||
});
|
||||
+352
-107
@@ -1,143 +1,388 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { ReadingSettings } from '../components/settings/ReadingSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AboutDataSettings } from '../components/settings/AboutDataSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
interface SettingItemProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
rightElement?: React.ReactNode;
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
| 'appearance' | 'layout'
|
||||
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'security' | 'encryption' | 'content_senders'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
function SettingItem({ icon, title, subtitle, rightElement }: SettingItemProps) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
|
||||
<View style={styles.settingIcon}>{icon}</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
mail: 'Mail',
|
||||
privacy: 'Privacy & Security',
|
||||
apps: 'Apps',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
// General
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
|
||||
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: false },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: false },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
|
||||
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: false },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: false },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: false },
|
||||
|
||||
// Privacy & Security
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: false },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false },
|
||||
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: false },
|
||||
|
||||
// Apps
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
|
||||
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
|
||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
|
||||
{ id: 'debug', label: 'Debug', icon: Bug, group: 'advanced', implemented: false },
|
||||
];
|
||||
|
||||
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
account: AccountSettings,
|
||||
notifications: NotificationSettings,
|
||||
reading: ReadingSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
about_data: AboutDataSettings,
|
||||
updates: UpdatesSettings,
|
||||
};
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
onBack?: () => void;
|
||||
onTabSelect?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) return;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
setSelectedTab(null);
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectedTab]);
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{Component ? <Component /> : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Settings size={24} color={colors.primary} />
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>B</Text>
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => handleTabPress(tab)}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.tabItem,
|
||||
disabled && styles.tabItemDisabled,
|
||||
!disabled && pressed && styles.tabItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>Experimental</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>Bulwark User</Text>
|
||||
<Text style={styles.profileEmail}>user@example.com</Text>
|
||||
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<SettingItem
|
||||
icon={<User size={20} color={colors.primary} />}
|
||||
title="Account Settings"
|
||||
subtitle="Manage your account details"
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Shield size={20} color={colors.primary} />}
|
||||
title="Privacy & Security"
|
||||
subtitle="Passwords, encryption, trusted senders"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Preferences</Text>
|
||||
<SettingItem
|
||||
icon={<Palette size={20} color={colors.primary} />}
|
||||
title="Dark Mode"
|
||||
rightElement={<Switch value={darkMode} onValueChange={setDarkMode} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Bell size={20} color={colors.primary} />}
|
||||
title="Notifications"
|
||||
rightElement={<Switch value={notifications} onValueChange={setNotifications} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
|
||||
<Text style={styles.footerText}>Built with Expo + React Native</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
margin: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary,
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
|
||||
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
|
||||
section: { marginTop: 8 },
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
settingRow: {
|
||||
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: { marginRight: 14 },
|
||||
settingContent: { flex: 1 },
|
||||
settingTitle: { fontSize: 16, color: colors.text },
|
||||
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
footer: {
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 4,
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
footerText: { fontSize: 12, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
User, Mail, CalendarDays, Shield, Bell, Palette, Globe, Lock,
|
||||
ChevronRight, LogOut, Moon, Server, Key, Fingerprint, Info, HelpCircle
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
interface SettingItem {
|
||||
id: string;
|
||||
icon: any;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
type: 'link' | 'toggle';
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@bulwark.mail',
|
||||
plan: 'Pro',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; items: SettingItem[] }[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{ id: 'profile', icon: User, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Profile', detail: PROFILE.email, type: 'link' },
|
||||
{ id: 'identities', icon: Mail, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Identities & Signatures', type: 'link' },
|
||||
{ id: 'security', icon: Lock, iconColor: colors.error, iconBg: colors.errorBg, label: 'Security', detail: '2FA enabled', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
items: [
|
||||
{ id: 'filters', icon: Mail, iconColor: colors.calendar.teal, iconBg: colors.calendar.tealBg, label: 'Filters & Rules', type: 'link' },
|
||||
{ id: 'smime', icon: Shield, iconColor: colors.calendar.indigo, iconBg: colors.calendar.indigoBg, label: 'S/MIME Encryption', type: 'link' },
|
||||
{ id: 'vacation', icon: CalendarDays, iconColor: colors.calendar.orange, iconBg: colors.warningBg, label: 'Vacation Responder', detail: 'Off', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
items: [
|
||||
{ id: 'theme', icon: Palette, iconColor: colors.calendar.pink, iconBg: colors.calendar.pinkBg, label: 'Theme', detail: 'System', type: 'link' },
|
||||
{ id: 'darkMode', icon: Moon, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Dark Mode', type: 'toggle', value: false },
|
||||
{ id: 'language', icon: Globe, iconColor: colors.calendar.blue, iconBg: colors.primaryBg, label: 'Language', detail: 'English', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
items: [
|
||||
{ id: 'pushNotif', icon: Bell, iconColor: colors.calendar.green, iconBg: colors.successBg, label: 'Push Notifications', type: 'toggle', value: true },
|
||||
{ id: 'emailNotif', icon: Mail, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Email Notifications', type: 'toggle', value: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
items: [
|
||||
{ id: 'server', icon: Server, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Server Settings', type: 'link' },
|
||||
{ id: 'privacy', icon: Fingerprint, iconColor: colors.textSecondary, iconBg: colors.surface, label: 'Privacy', type: 'link' },
|
||||
{ id: 'about', icon: Info, iconColor: colors.textMuted, iconBg: colors.surface, label: 'About', detail: 'v1.0.0', type: 'link' },
|
||||
{ id: 'help', icon: HelpCircle, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Help & Support', type: 'link' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SettingRow({ item }: { item: SettingItem }) {
|
||||
const Icon = item.icon;
|
||||
const [toggled, setToggled] = React.useState(item.value ?? false);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]}
|
||||
>
|
||||
<View style={[styles.settingIcon, { backgroundColor: item.iconBg }]}>
|
||||
<Icon size={18} color={item.iconColor} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingLabel}>{item.label}</Text>
|
||||
{item.detail && item.type === 'link' && (
|
||||
<Text style={styles.settingDetail}>{item.detail}</Text>
|
||||
)}
|
||||
</View>
|
||||
{item.type === 'link' && (
|
||||
<ChevronRight size={18} color={colors.textMuted} />
|
||||
)}
|
||||
{item.type === 'toggle' && (
|
||||
<Switch
|
||||
value={toggled}
|
||||
onValueChange={setToggled}
|
||||
trackColor={{ false: colors.surface, true: colors.primary }}
|
||||
thumbColor={colors.background}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout }: SettingsScreenProps) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Profile card */}
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>JD</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>{PROFILE.name}</Text>
|
||||
<Text style={styles.profileEmail}>{PROFILE.email}</Text>
|
||||
</View>
|
||||
<View style={styles.planBadge}>
|
||||
<Text style={styles.planBadgeText}>{PROFILE.plan}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Settings sections */}
|
||||
{SECTIONS.map(section => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.sectionCard}>
|
||||
{section.items.map((item, idx) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{idx > 0 && <View style={styles.sectionSeparator} />}
|
||||
<SettingRow item={item} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Sign out */}
|
||||
<Pressable style={styles.signOutBtn} onPress={onLogout}>
|
||||
<LogOut size={18} color={colors.error} />
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Version info */}
|
||||
<Text style={styles.versionText}>Bulwark Mobile v0.0.1</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
scrollContent: { paddingBottom: 40 },
|
||||
|
||||
// Profile card
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: spacing.lg,
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.xl,
|
||||
gap: spacing.md,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: componentSizes.avatarLg, height: componentSizes.avatarLg,
|
||||
borderRadius: componentSizes.avatarLg / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
profileAvatarText: { ...typography.h3, color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { ...typography.bodyMedium, color: colors.text },
|
||||
profileEmail: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
planBadge: {
|
||||
backgroundColor: colors.primaryBg,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
planBadgeText: { ...typography.small, color: colors.primary },
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
sectionCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderLight,
|
||||
marginLeft: 56,
|
||||
},
|
||||
|
||||
// Setting row
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: {
|
||||
width: 32, height: 32,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
settingContent: { flex: 1 },
|
||||
settingLabel: { ...typography.body, color: colors.text },
|
||||
settingDetail: { ...typography.small, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Sign out
|
||||
signOutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
signOutText: { ...typography.bodyMedium, color: colors.error },
|
||||
|
||||
// Version
|
||||
versionText: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { EmailSettings } from '../components/settings/EmailSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
type Tab =
|
||||
| 'appearance' | 'email' | 'notifications'
|
||||
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'themes' | 'plugins' | 'updates' | 'advanced';
|
||||
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||
general: 'General',
|
||||
account: 'Account',
|
||||
organization: 'Organization',
|
||||
apps: 'Apps',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general', implemented: false },
|
||||
{ id: 'email', label: 'Email', icon: Mail, group: 'general', implemented: true },
|
||||
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'account', implemented: true },
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'account', implemented: false },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account', implemented: true },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account', implemented: false },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization', implemented: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization', implemented: false },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization', implemented: false },
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
|
||||
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
|
||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
|
||||
];
|
||||
|
||||
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
email: EmailSettings,
|
||||
notifications: NotificationSettings,
|
||||
account: AccountSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
updates: UpdatesSettings,
|
||||
advanced: AdvancedSettings,
|
||||
};
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
onBack?: () => void;
|
||||
onTabSelect?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) return;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
setSelectedTab(null);
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectedTab]);
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{Component ? <Component /> : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => handleTabPress(tab)}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.tabItem,
|
||||
disabled && styles.tabItemDisabled,
|
||||
!disabled && pressed && styles.tabItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>Experimental</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user