Compare commits

...
15 Commits
Author SHA1 Message Date
Linus Rath 0d7bf3204a chore: update version number to 0.1.7 2026-04-26 22:23:26 +02:00
Linus Rath a76e0fe8c0 feat: enhance APK installation process with progress tracking and UI updates 2026-04-26 22:22:52 +02:00
Linus Rath cee4193e58 chore: update version number to 0.1.6 2026-04-26 21:59:14 +02:00
Linus Rath 5ac2a142b5 feat: update Android build configuration to use arm64-v8a architecture and improve caching mechanism 2026-04-26 21:58:08 +02:00
Linus Rath e73dc4e5ba feat: add GitHub release creation after version bump 2026-04-26 21:40:25 +02:00
Linus Rath 892ff33f14 chore: update version number to 0.1.5 2026-04-26 21:38:21 +02:00
Linus Rath 7e42cb85ea feat: add EmailSourceScreen for viewing and sharing email source, update navigation and types 2026-04-26 21:38:07 +02:00
Linus Rath 02c590beb3 chore: update version to 0.1.4 in VERSION, package.json, and package-lock.json 2026-04-26 21:20:18 +02:00
Linus Rath e5bbfeb26e refactor: optimize EmailRow component and improve row press handling in EmailListScreen 2026-04-26 21:19:08 +02:00
Linus Rath 373c66f2ba feat: implement navigation between emails in EmailThreadScreen with previous and next buttons 2026-04-26 20:55:29 +02:00
Linus Rath 07a18595aa Refactor SettingsScreen: Consolidate settings tabs, improve layout, and enhance user experience 2026-04-26 20:49:17 +02:00
Linus Rath 2309845274 feat: add AdvancedSettings and EmailSettings components for enhanced user configuration 2026-04-26 20:49:06 +02:00
Linus Rath 04815c4e60 feat: add back button handling in SettingsScreen to manage tab selection 2026-04-26 20:31:44 +02:00
Linus Rath fbfb887e0a chore: update version number to 0.1.3 2026-04-26 20:28:29 +02:00
Linus Rath 10db4c503a feat: update APK installation process to use expo-intent-launcher and improve versioning 2026-04-26 20:27:30 +02:00
28 changed files with 1811 additions and 2112 deletions
+11 -1
View File
@@ -42,6 +42,16 @@ jobs:
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
restore-keys: android-sdk-${{ runner.os }}-
- name: Compute native cache key input
run: |
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
delete lock.version;
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
"
- name: Cache native CMake build outputs
uses: actions/cache@v4
with:
@@ -50,7 +60,7 @@ jobs:
android/app/build/intermediates/cxx
android/app/build/intermediates/merged_native_libs
android/app/build/intermediates/stripped_native_libs
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
restore-keys: cxx-${{ runner.os }}-
- name: Install JS dependencies
+5 -3
View File
@@ -19,15 +19,16 @@ import {
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
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';
@@ -325,6 +326,7 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
+1 -1
View File
@@ -1 +1 @@
0.1.2
0.1.7
+1
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+1 -1
View File
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
+13 -2
View File
@@ -1,12 +1,13 @@
{
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.7",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
@@ -17,6 +18,7 @@
"expo": "~54.0.33",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
@@ -5234,6 +5236,15 @@
"expo": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "13.0.8",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz",
"integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.2",
"version": "0.1.7",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -9,7 +9,8 @@
"ios": "expo run:ios",
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"bump": "node scripts/bump-version.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -21,6 +22,7 @@
"expo": "~54.0.33",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const versionFile = path.join(root, 'VERSION');
const pkgFile = path.join(root, 'package.json');
const lockFile = path.join(root, 'package-lock.json');
const git = (...args) =>
execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const gitInherit = (...args) =>
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
const dirty = git('status', '--porcelain');
if (dirty) {
console.error('Working tree is not clean. Commit or stash changes before bumping.');
console.error(dirty);
process.exit(1);
}
const current = fs.readFileSync(versionFile, 'utf8').trim();
const parts = current.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
console.error(`Invalid version in VERSION: "${current}" (expected MAJOR.MINOR.PATCH)`);
process.exit(1);
}
parts[2] += 1;
const next = parts.join('.');
const existingTags = new Set(git('tag', '--list').split(/\r?\n/).filter(Boolean));
if (existingTags.has(next)) {
console.error(`Tag ${next} already exists.`);
process.exit(1);
}
fs.writeFileSync(versionFile, `${next}\n`);
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
pkg.version = next;
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
lock.version = next;
if (lock.packages?.['']) lock.packages[''].version = next;
fs.writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
console.log(`${current} -> ${next}`);
gitInherit('add', 'VERSION', 'package.json', 'package-lock.json');
gitInherit('commit', '-m', `chore: update version number to ${next}`);
gitInherit('tag', '-a', next, '-m', next);
gitInherit('push', '--follow-tags');
execFileSync(
'gh',
['release', 'create', next, '--title', next, '--generate-notes'],
{ cwd: root, stdio: 'inherit' },
);
console.log(`Released ${next}`);
+3 -1
View File
@@ -32,7 +32,7 @@ interface MonthViewProps {
onLongPressDate?: (date: Date) => void;
}
export function MonthView({
function MonthViewInner({
currentDate,
selectedDate,
events,
@@ -118,6 +118,8 @@ export function MonthView({
);
}
export const MonthView = React.memo(MonthViewInner);
const styles = StyleSheet.create({
grid: { paddingHorizontal: spacing.sm },
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
+3 -1
View File
@@ -39,7 +39,7 @@ interface WeekViewProps {
weekStartsOn?: 0 | 1;
}
export function WeekView({
function WeekViewInner({
selectedDate,
events,
calendars,
@@ -303,6 +303,8 @@ export function WeekView({
);
}
export const WeekView = React.memo(WeekViewInner);
function minutesToTimeLabel(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
@@ -17,7 +17,7 @@ const DEBUG_CATEGORIES = [
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
export function AdvancedSettings() {
export function AboutDataSettings() {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
@@ -65,7 +65,7 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
);
}
export function EmailSettings() {
export function ReadingSettings() {
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
+67 -1
View File
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useUpdatesStore } from '../../stores/updates-store';
import type { InstallProgress } from '../../lib/install-update';
function formatTime(ts: number): string {
if (!ts) return 'never';
@@ -11,11 +12,37 @@ function formatTime(ts: number): string {
return date.toLocaleString();
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function formatProgressLabel(p: InstallProgress): string {
const pct = p.progress != null ? `${Math.round(p.progress * 100)}%` : null;
const written = p.bytesWritten ? formatBytes(p.bytesWritten) : null;
const total = p.totalBytes ? formatBytes(p.totalBytes) : null;
const sizes = written && total ? `${written} of ${total}` : written ?? '';
return [pct, sizes].filter(Boolean).join(' · ') || 'Downloading…';
}
function installButtonLabel(
installing: boolean,
progress: InstallProgress | null,
apkAsset: unknown,
): string {
if (!installing) return apkAsset ? 'Install' : 'Open release';
if (progress?.phase === 'installing') return 'Installing…';
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
return 'Downloading…';
}
export function UpdatesSettings() {
const hydrated = useUpdatesStore((s) => s.hydrated);
const hydrate = useUpdatesStore((s) => s.hydrate);
const checking = useUpdatesStore((s) => s.checking);
const installing = useUpdatesStore((s) => s.installing);
const installProgress = useUpdatesStore((s) => s.installProgress);
const error = useUpdatesStore((s) => s.error);
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
@@ -81,7 +108,7 @@ export function UpdatesSettings() {
onPress={onInstall}
disabled={installing}
>
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
{installButtonLabel(installing, installProgress, apkAsset)}
</Button>
) : (
<Button
@@ -95,6 +122,29 @@ export function UpdatesSettings() {
)}
</SettingItem>
{installing && installProgress ? (
<View style={styles.progressBox}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width:
installProgress.phase === 'installing'
? '100%'
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
},
]}
/>
</View>
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? 'Opening installer…'
: formatProgressLabel(installProgress)}
</Text>
</View>
) : null}
{cachedLatest?.body ? (
<View style={styles.notesBox}>
<Text style={styles.notesTitle}>Release notes</Text>
@@ -121,4 +171,20 @@ const styles = StyleSheet.create({
},
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: 6,
},
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: colors.muted,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: colors.primary,
},
progressLabel: { ...typography.caption, color: colors.mutedForeground },
});
+41
View File
@@ -0,0 +1,41 @@
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
}
function safeFilename(subject: string | undefined): string {
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
return `${base}.eml`;
}
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
if (dest.exists) dest.delete();
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
});
}
+66 -9
View File
@@ -1,21 +1,78 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import type { ReleaseAsset } from '../api/updates';
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
export interface InstallProgress {
phase: 'downloading' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
bytesWritten?: number;
/** Total bytes for the download (best-effort, may be 0 if server omits Content-Length) */
totalBytes?: number;
}
export type InstallProgressListener = (p: InstallProgress) => void;
export async function downloadAndInstallApk(
asset: ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
const dest = new File(Paths.cache, asset.name);
if (dest.exists) dest.delete();
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
onProgress?.({ phase: 'downloading', progress: 0 });
const download = FileSystemLegacy.createDownloadResumable(
asset.browser_download_url,
dest.uri,
{},
(state) => {
const total = state.totalBytesExpectedToWrite ?? 0;
const progress = total > 0 ? state.totalBytesWritten / total : undefined;
onProgress?.({
phase: 'downloading',
progress,
bytesWritten: state.totalBytesWritten,
totalBytes: total,
});
},
);
const result = await download.downloadAsync();
if (!result?.uri) {
throw new Error('APK download failed');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType: 'application/vnd.android.package-archive',
dialogTitle: 'Install update',
UTI: 'com.android.package-archive',
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
type: 'application/vnd.android.package-archive',
flags: FLAG_GRANT_READ_URI_PERMISSION | FLAG_ACTIVITY_NEW_TASK,
});
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
* Useful as a fallback if the install intent never reaches the installer.
*/
export async function openInstallUnknownAppsSettings(): Promise<void> {
if (Platform.OS !== 'android') return;
await IntentLauncher.startActivityAsync(
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
{ data: 'package:com.anonymous.bulwarkmobile' },
);
}
+1
View File
@@ -3,6 +3,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
Compose:
| {
mode?: 'reply' | 'replyAll' | 'forward';
+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={openCreate}
/>
)}
{viewMode === 'week' && (
<WeekView
selectedDate={selectedDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
onSelectDate={handleSelectDate}
onSelectEvent={setDetailEvent}
onCreateAtTime={openCreate}
/>
)}
{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 },
});
+48 -20
View File
@@ -43,7 +43,7 @@ function formatRelativeTime(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function EmailRow({
const EmailRow = React.memo(function EmailRow({
item,
onPress,
onLongPress,
@@ -51,8 +51,8 @@ function EmailRow({
selectionMode,
}: {
item: Email;
onPress: () => void;
onLongPress: () => void;
onPress: (id: string) => void;
onLongPress: (id: string) => void;
selected: boolean;
selectionMode: boolean;
}) {
@@ -61,6 +61,9 @@ function EmailRow({
const unread = isUnread(item);
const starred = isStarred(item);
const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]);
const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]);
return (
<Pressable
style={({ pressed }) => [
@@ -68,8 +71,8 @@ function EmailRow({
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
onPress={onPress}
onLongPress={onLongPress}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
{selectionMode && (
@@ -117,7 +120,11 @@ function EmailRow({
</View>
</Pressable>
);
}
});
const EmailRowSeparator = () => <View style={styles.separator} />;
const emailKeyExtractor = (item: Email) => item.id;
interface EmailListScreenProps {
onEmailPress?: (email: Email) => void;
@@ -152,6 +159,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
// Refs so row press handlers stay referentially stable across renders.
// FlatList rows then skip re-render when the parent re-renders for unrelated
// reasons (e.g. opening the filter modal).
const onEmailPressRef = React.useRef(onEmailPress);
React.useEffect(() => { onEmailPressRef.current = onEmailPress; }, [onEmailPress]);
const selectionModeRef = React.useRef(selectionMode);
React.useEffect(() => { selectionModeRef.current = selectionMode; }, [selectionMode]);
const emailsRef = React.useRef(emails);
React.useEffect(() => { emailsRef.current = emails; }, [emails]);
const toggleSelect = React.useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -161,6 +178,28 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
});
}, []);
const handleRowPress = React.useCallback((id: string) => {
if (selectionModeRef.current) {
toggleSelect(id);
} else {
const email = emailsRef.current.find((e) => e.id === id);
if (email) onEmailPressRef.current?.(email);
}
}, [toggleSelect]);
const renderEmailRow = React.useCallback(
({ item }: { item: Email }) => (
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
onLongPress={toggleSelect}
/>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect],
);
const clearSelection = React.useCallback(() => {
setSelectedIds(new Set());
}, []);
@@ -488,20 +527,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
) : (
<FlatList
data={emails}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={() => {
if (selectionMode) toggleSelect(item.id);
else onEmailPress?.(item);
}}
onLongPress={() => toggleSelect(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
keyExtractor={emailKeyExtractor}
renderItem={renderEmailRow}
ItemSeparatorComponent={EmailRowSeparator}
contentContainerStyle={styles.listContent}
onEndReached={() => { void loadMoreEmails(); }}
onEndReachedThreshold={0.3}
-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 },
});
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, Share2 } from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
import { colors, spacing, typography, componentSizes, radius } from '../theme/tokens';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const { blobId, subject } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const text = await fetchRawEmail(blobId);
if (!cancelled) setRaw(text);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
}
})();
return () => { cancelled = true; };
}, [blobId]);
const onShare = async () => {
try {
await shareEmailEml(blobId, subject);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{subject || 'Email source'}
</Text>
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
<Share2 size={20} color={raw ? colors.text : colors.textMuted} />
</Pressable>
</View>
{!raw && !error ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary} />
</View>
) : error ? (
<View style={styles.loading}>
<Text style={styles.error}>{error}</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.source}>{raw}</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
body: { padding: spacing.lg },
source: {
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 16,
color: colors.text,
},
});
+62 -7
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing,
Modal, useWindowDimensions, Animated, Easing, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -9,7 +9,7 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
@@ -17,6 +17,7 @@ import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml } from '../lib/email-export';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -67,6 +68,18 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
const currentIndex = emails.findIndex((e) => e.id === emailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject,
});
};
const [email, setEmail] = React.useState<Email | null>(null);
const [loading, setLoading] = React.useState(true);
@@ -342,7 +355,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<BottomBarButton
icon={<ChevronLeft size={20} color={colors.textMuted} />}
label="Prev"
disabled
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
disabled={!prevEmail}
/>
<BottomBarButton
icon={<Reply size={20} color={colors.textSecondary} />}
@@ -362,7 +376,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<BottomBarButton
icon={<ChevronRight size={20} color={colors.textMuted} />}
label="Next"
disabled
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
disabled={!nextEmail}
/>
</View>
</>
@@ -372,15 +387,36 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
visible={moreMenuOpen}
onClose={() => setMoreMenuOpen(false)}
unread={unread}
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={!showMarkUnread}
canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={true}
canMove={mailboxes.length > 0}
showSpam={!!junkMailbox || isInJunk}
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
onToggleSpam={onToggleSpam}
onViewSource={() => {
setMoreMenuOpen(false);
if (email?.blobId) {
navigation.navigate('EmailSource', {
emailId: email.id,
blobId: email.blobId,
subject: email.subject,
});
}
}}
onExport={async () => {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email.subject);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
}}
/>
<MoveMenuSheet
@@ -403,15 +439,20 @@ interface MoreMenuSheetProps {
canMove: boolean;
showSpam: boolean;
isInJunk: boolean;
canViewSource: boolean;
canExport: boolean;
onArchive: () => void;
onToggleUnread: () => void;
onMove: () => void;
onToggleSpam: () => void;
onViewSource: () => void;
onExport: () => void;
}
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove,
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
showSpam, isInJunk, canViewSource, canExport,
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
}: MoreMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).current;
@@ -490,6 +531,20 @@ function MoreMenuSheet({
onPress={onToggleSpam}
/>
)}
{canViewSource && (
<MoreMenuItem
icon={<Code size={18} color={colors.textSecondary} />}
label="View source"
onPress={onViewSource}
/>
)}
{canExport && (
<MoreMenuItem
icon={<Download size={18} color={colors.textSecondary} />}
label="Export email (.eml)"
onPress={onExport}
/>
)}
</Animated.View>
</Modal>
);
+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,
},
});
-359
View File
@@ -1,359 +0,0 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } 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();
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,
},
});
+13 -5
View File
@@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import Constants from 'expo-constants';
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
import { isNewer } from '../lib/version-compare';
import { downloadAndInstallApk } from '../lib/install-update';
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -26,6 +26,7 @@ export interface UpdatesState extends PersistedUpdates {
hydrated: boolean;
checking: boolean;
installing: boolean;
installProgress: InstallProgress | null;
error: string | null;
hydrate: () => Promise<void>;
@@ -48,6 +49,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
hydrated: false,
checking: false,
installing: false,
installProgress: null,
error: null,
hydrate: async () => {
@@ -100,12 +102,18 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
installLatest: async () => {
const s = get();
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null });
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset);
set({ installing: false });
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
set({ installProgress: p });
});
set({ installing: false, installProgress: null });
} catch (err) {
set({ installing: false, error: err instanceof Error ? err.message : String(err) });
set({
installing: false,
installProgress: null,
error: err instanceof Error ? err.message : String(err),
});
}
},