Refactor SettingsScreen: Consolidate settings tabs, improve layout, and enhance user experience

This commit is contained in:
Linus Rath
2026-04-26 20:49:17 +02:00
parent 2309845274
commit 07a18595aa
11 changed files with 1316 additions and 2070 deletions
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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 },
});
-546
View File
@@ -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
View File
@@ -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 },
});
-500
View File
@@ -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 },
});
-106
View File
@@ -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
View File
@@ -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 },
});
-250
View File
@@ -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,
},
});
-368
View File
@@ -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,
},
});