mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7bf3204a | ||
|
|
a76e0fe8c0 | ||
|
|
cee4193e58 | ||
|
|
5ac2a142b5 | ||
|
|
e73dc4e5ba | ||
|
|
892ff33f14 | ||
|
|
7e42cb85ea | ||
|
|
02c590beb3 | ||
|
|
e5bbfeb26e | ||
|
|
373c66f2ba | ||
|
|
07a18595aa | ||
|
|
2309845274 | ||
|
|
04815c4e60 | ||
|
|
fbfb887e0a | ||
|
|
10db4c503a |
@@ -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
|
||||
|
||||
@@ -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,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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+13
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const DEBUG_CATEGORIES = [
|
||||
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
|
||||
];
|
||||
|
||||
export function AdvancedSettings() {
|
||||
export function AboutDataSettings() {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
+1
-1
@@ -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);
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Menu,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isToday,
|
||||
addMonths,
|
||||
subMonths,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
} from 'date-fns';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { Button } from '../components';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
import { MonthView } from '../components/calendar/MonthView';
|
||||
import { WeekView } from '../components/calendar/WeekView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
import { EventCard } from '../components/calendar/EventCard';
|
||||
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
|
||||
import { EventModal } from '../components/calendar/EventModal';
|
||||
import {
|
||||
RecurrenceScopeDialog,
|
||||
type RecurrenceEditScope,
|
||||
} from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
type EventDayIndex,
|
||||
} from '../lib/calendar-utils';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
type PendingAction =
|
||||
| { kind: 'edit'; event: CalendarEvent }
|
||||
| { kind: 'delete'; event: CalendarEvent }
|
||||
| null;
|
||||
|
||||
const AGENDA_DAYS = 30;
|
||||
const RANGE_BUFFER_DAYS = 14;
|
||||
|
||||
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
|
||||
if (viewMode === 'month') {
|
||||
const start = startOfWeek(startOfMonth(currentDate));
|
||||
const end = endOfWeek(endOfMonth(currentDate));
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
return {
|
||||
after: addDays(currentDate, -1),
|
||||
before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
|
||||
};
|
||||
}
|
||||
|
||||
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
return format(currentDate, 'MMMM yyyy');
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const hydrate = useCalendarStore((s) => s.hydrate);
|
||||
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
|
||||
const ensureRange = useCalendarStore((s) => s.ensureRange);
|
||||
const refresh = useCalendarStore((s) => s.refresh);
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const allCalendars = useCalendarStore((s) => s.calendars);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
const allEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
[allCalendars, hiddenCalendarIds],
|
||||
);
|
||||
const events = React.useMemo(() => {
|
||||
if (hiddenCalendarIds.length === 0) return allEvents;
|
||||
const hidden = new Set(hiddenCalendarIds);
|
||||
return allEvents.filter((e) => {
|
||||
const ids = Object.keys(e.calendarIds || {});
|
||||
if (ids.length === 0) return true;
|
||||
return ids.some((id) => !hidden.has(id));
|
||||
});
|
||||
}, [allEvents, hiddenCalendarIds]);
|
||||
// Pre-index events by day once. Child views do O(1) map lookups per cell
|
||||
// instead of re-filtering the full event list with parseISO per day.
|
||||
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
void fetchCalendarsAction();
|
||||
}, [hydrate, fetchCalendarsAction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { after, before } = rangeForView(viewMode, currentDate);
|
||||
void ensureRange(after.toISOString(), before.toISOString());
|
||||
}, [viewMode, currentDate, ensureRange]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return subMonths(d, 1);
|
||||
if (viewMode === 'week') return subWeeks(d, 1);
|
||||
return addDays(d, -AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return addMonths(d, 1);
|
||||
if (viewMode === 'week') return addWeeks(d, 1);
|
||||
return addDays(d, AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goToday = React.useCallback(() => {
|
||||
const today = new Date();
|
||||
setCurrentDate(today);
|
||||
setSelectedDate(today);
|
||||
}, []);
|
||||
|
||||
const handleSelectDate = React.useCallback(
|
||||
(date: Date) => {
|
||||
setSelectedDate(date);
|
||||
if (viewMode === 'week') setCurrentDate(date);
|
||||
},
|
||||
[viewMode],
|
||||
);
|
||||
|
||||
const openCreate = React.useCallback((date?: Date) => {
|
||||
setModalEvent(null);
|
||||
setModalDate(date);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const openEditDirect = React.useCallback((event: CalendarEvent) => {
|
||||
setModalEvent(event);
|
||||
setModalDate(undefined);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" - proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
if (action.kind === 'edit') {
|
||||
openEditDirect(action.event);
|
||||
} else {
|
||||
await deleteEvent(action.event.id);
|
||||
}
|
||||
},
|
||||
[pendingAction, openEditDirect, deleteEvent],
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (data: Partial<CalendarEvent>, calendarId: string) => {
|
||||
if (modalEvent) {
|
||||
await updateEvent(modalEvent.id, data);
|
||||
} else {
|
||||
await createEvent(data, calendarId);
|
||||
}
|
||||
},
|
||||
[modalEvent, createEvent, updateEvent],
|
||||
);
|
||||
|
||||
const handleDeleteFromModal = React.useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
setModalVisible(false);
|
||||
await deleteEvent(event.id);
|
||||
},
|
||||
[deleteEvent],
|
||||
);
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const isSelectedToday = isToday(selectedDate);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSidebarVisible(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode}
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
fromDate={currentDate}
|
||||
daysAhead={AGENDA_DAYS}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
</View>
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
/>
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: {
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const dayEvents = React.useMemo(
|
||||
() => eventsOnDayFromIndex(eventsByDay, date),
|
||||
[eventsByDay, date],
|
||||
);
|
||||
if (dayEvents.length === 0) {
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerLeft: { flex: 1 },
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
viewToggle: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.md,
|
||||
padding: 2,
|
||||
},
|
||||
viewToggleBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewToggleBtnActive: { backgroundColor: colors.background },
|
||||
fab: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
nav: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
navBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
errorBanner: {
|
||||
backgroundColor: colors.errorBg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
content: { flex: 1 },
|
||||
|
||||
dayDetail: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
dayDetailHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
gap: spacing.xs,
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
+460
-70
@@ -1,110 +1,500 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native';
|
||||
import { Users } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import {
|
||||
useContactsStore,
|
||||
sortContactsByDisplayName,
|
||||
selectGroupMembers,
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
const MOCK_CONTACTS = [
|
||||
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101' },
|
||||
{ id: '2', name: 'Bob Smith', email: 'bob@example.com', phone: '+1 555-0102' },
|
||||
{ id: '3', name: 'Carol Davis', email: 'carol@example.com', phone: '+1 555-0103' },
|
||||
{ id: '4', name: 'David Wilson', email: 'david@example.com', phone: '+1 555-0104' },
|
||||
{ id: '5', name: 'Emma Brown', email: 'emma@example.com', phone: '+1 555-0105' },
|
||||
{ id: '6', name: 'Frank Miller', email: 'frank@example.com', phone: '+1 555-0106' },
|
||||
{ id: '7', name: 'Grace Lee', email: 'grace@example.com', phone: '+1 555-0107' },
|
||||
{ id: '8', name: 'Henry Taylor', email: 'henry@example.com', phone: '+1 555-0108' },
|
||||
];
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function groupByLetter(contacts: typeof MOCK_CONTACTS) {
|
||||
const groups: Record<string, typeof MOCK_CONTACTS> = {};
|
||||
contacts.forEach((c) => {
|
||||
const letter = c.name.charAt(0).toUpperCase();
|
||||
if (!groups[letter]) groups[letter] = [];
|
||||
groups[letter].push(c);
|
||||
});
|
||||
return Object.keys(groups)
|
||||
.sort()
|
||||
.map((letter) => ({ title: letter, data: groups[letter] }));
|
||||
interface Section {
|
||||
title: string;
|
||||
data: ContactCard[];
|
||||
}
|
||||
|
||||
function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
|
||||
</View>
|
||||
<View style={styles.contactInfo}>
|
||||
<Text style={styles.contactName}>{item.name}</Text>
|
||||
<Text style={styles.contactEmail}>{item.email}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
for (const c of sorted) {
|
||||
const name = getContactDisplayName(c).trim();
|
||||
const letter = (name[0] || '#').toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(c);
|
||||
}
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => ({ title, data: groups[title] }));
|
||||
}
|
||||
|
||||
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
|
||||
switch (category.type) {
|
||||
case 'all':
|
||||
return 'All Contacts';
|
||||
case 'addressBook':
|
||||
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
|
||||
case 'group': {
|
||||
const g = groups.find((c) => c.id === category.groupId);
|
||||
return g ? getContactDisplayName(g) || 'Group' : 'Group';
|
||||
}
|
||||
case 'keyword':
|
||||
return `#${category.keyword}`;
|
||||
case 'uncategorized':
|
||||
return 'Uncategorized';
|
||||
default:
|
||||
return 'Contacts';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const loading = useContactsStore((s) => s.loading);
|
||||
const error = useContactsStore((s) => s.error);
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const hydrated = useContactsStore((s) => s.hydrated);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [searchActive, setSearchActive] = React.useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
let filtered: ContactCard[];
|
||||
switch (selectedCategory.type) {
|
||||
case 'all':
|
||||
filtered = individuals;
|
||||
break;
|
||||
case 'addressBook':
|
||||
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
|
||||
break;
|
||||
case 'group':
|
||||
filtered = selectGroupMembers(
|
||||
{ contacts } as Parameters<typeof selectGroupMembers>[0],
|
||||
selectedCategory.groupId,
|
||||
).filter((c) => !isGroup(c));
|
||||
break;
|
||||
case 'keyword':
|
||||
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
||||
break;
|
||||
case 'uncategorized':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
const selectionMode = selection.size > 0;
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowPress = (c: ContactCard) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(c.id);
|
||||
return;
|
||||
}
|
||||
if (isGroup(c)) {
|
||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||
} else {
|
||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = (c: ContactCard) => {
|
||||
toggleSelect(c.id);
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelection(new Set());
|
||||
|
||||
const doBulkDelete = async () => {
|
||||
const ids = Array.from(selection);
|
||||
setConfirmBulkDelete(false);
|
||||
clearSelection();
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
const chips = React.useMemo(() => {
|
||||
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
category: { type: 'all' },
|
||||
active: selectedCategory.type === 'all',
|
||||
},
|
||||
];
|
||||
for (const book of addressBooks) {
|
||||
items.push({
|
||||
key: `book:${book.id}`,
|
||||
label: book.name,
|
||||
category: { type: 'addressBook', addressBookId: book.id },
|
||||
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
|
||||
});
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
items.push({
|
||||
key: 'groups-divider',
|
||||
label: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Users size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Contacts</Text>
|
||||
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerCount}>{visible.length}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setSearchActive((v) => !v)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.chipBar}
|
||||
contentContainerStyle={styles.chipBarContent}
|
||||
>
|
||||
{chips.map((chip) =>
|
||||
chip.key === 'groups-divider' ? (
|
||||
<View key={chip.key} style={styles.chipDivider} />
|
||||
) : (
|
||||
<Pressable
|
||||
key={chip.key}
|
||||
onPress={() => setSelectedCategory(chip.category)}
|
||||
style={[styles.chip, chip.active && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <ContactRow item={item} />}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
renderItem={({ item }) => (
|
||||
<ContactListRow
|
||||
contact={item}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
selected={selection.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
)}
|
||||
renderSectionHeader={({ section }) =>
|
||||
section.title ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
stickySectionHeadersEnabled
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading && hydrated}
|
||||
onRefresh={() => {
|
||||
void fetchContacts();
|
||||
void fetchAddressBooks();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
|
||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
||||
listContent: { paddingBottom: 20 },
|
||||
sectionHeader: {
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
||||
contactRow: {
|
||||
headerActions: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
contactRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
avatar: {
|
||||
headerIconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse },
|
||||
contactInfo: { flex: 1 },
|
||||
contactName: { fontSize: 15, fontWeight: '500', color: colors.text },
|
||||
contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
|
||||
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
||||
chipBarContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import {
|
||||
useContactsStore,
|
||||
sortContactsByDisplayName,
|
||||
selectGroupMembers,
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
data: ContactCard[];
|
||||
}
|
||||
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
for (const c of sorted) {
|
||||
const name = getContactDisplayName(c).trim();
|
||||
const letter = (name[0] || '#').toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(c);
|
||||
}
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => ({ title, data: groups[title] }));
|
||||
}
|
||||
|
||||
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
|
||||
switch (category.type) {
|
||||
case 'all':
|
||||
return 'All Contacts';
|
||||
case 'addressBook':
|
||||
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
|
||||
case 'group': {
|
||||
const g = groups.find((c) => c.id === category.groupId);
|
||||
return g ? getContactDisplayName(g) || 'Group' : 'Group';
|
||||
}
|
||||
case 'keyword':
|
||||
return `#${category.keyword}`;
|
||||
case 'uncategorized':
|
||||
return 'Uncategorized';
|
||||
default:
|
||||
return 'Contacts';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const loading = useContactsStore((s) => s.loading);
|
||||
const error = useContactsStore((s) => s.error);
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const hydrated = useContactsStore((s) => s.hydrated);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [searchActive, setSearchActive] = React.useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
let filtered: ContactCard[];
|
||||
switch (selectedCategory.type) {
|
||||
case 'all':
|
||||
filtered = individuals;
|
||||
break;
|
||||
case 'addressBook':
|
||||
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
|
||||
break;
|
||||
case 'group':
|
||||
filtered = selectGroupMembers(
|
||||
{ contacts } as Parameters<typeof selectGroupMembers>[0],
|
||||
selectedCategory.groupId,
|
||||
).filter((c) => !isGroup(c));
|
||||
break;
|
||||
case 'keyword':
|
||||
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
||||
break;
|
||||
case 'uncategorized':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
const selectionMode = selection.size > 0;
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowPress = (c: ContactCard) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(c.id);
|
||||
return;
|
||||
}
|
||||
if (isGroup(c)) {
|
||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||
} else {
|
||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = (c: ContactCard) => {
|
||||
toggleSelect(c.id);
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelection(new Set());
|
||||
|
||||
const doBulkDelete = async () => {
|
||||
const ids = Array.from(selection);
|
||||
setConfirmBulkDelete(false);
|
||||
clearSelection();
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
const chips = React.useMemo(() => {
|
||||
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
category: { type: 'all' },
|
||||
active: selectedCategory.type === 'all',
|
||||
},
|
||||
];
|
||||
for (const book of addressBooks) {
|
||||
items.push({
|
||||
key: `book:${book.id}`,
|
||||
label: book.name,
|
||||
category: { type: 'addressBook', addressBookId: book.id },
|
||||
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
|
||||
});
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
items.push({
|
||||
key: 'groups-divider',
|
||||
label: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
<View style={styles.header}>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerCount}>{visible.length}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setSearchActive((v) => !v)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.chipBar}
|
||||
contentContainerStyle={styles.chipBarContent}
|
||||
>
|
||||
{chips.map((chip) =>
|
||||
chip.key === 'groups-divider' ? (
|
||||
<View key={chip.key} style={styles.chipDivider} />
|
||||
) : (
|
||||
<Pressable
|
||||
key={chip.key}
|
||||
onPress={() => setSelectedCategory(chip.category)}
|
||||
style={[styles.chip, chip.active && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ContactListRow
|
||||
contact={item}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
selected={selection.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
)}
|
||||
renderSectionHeader={({ section }) =>
|
||||
section.title ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
stickySectionHeadersEnabled
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading && hydrated}
|
||||
onRefresh={() => {
|
||||
void fetchContacts();
|
||||
void fetchAddressBooks();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerActions: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerIconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
|
||||
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
||||
chipBarContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -1,143 +1,388 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { ReadingSettings } from '../components/settings/ReadingSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AboutDataSettings } from '../components/settings/AboutDataSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
interface SettingItemProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
rightElement?: React.ReactNode;
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
| 'appearance' | 'layout'
|
||||
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'security' | 'encryption' | 'content_senders'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
function SettingItem({ icon, title, subtitle, rightElement }: SettingItemProps) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
|
||||
<View style={styles.settingIcon}>{icon}</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
mail: 'Mail',
|
||||
privacy: 'Privacy & Security',
|
||||
apps: 'Apps',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
// General
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
|
||||
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: false },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: false },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
|
||||
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: false },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: false },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: false },
|
||||
|
||||
// Privacy & Security
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: false },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false },
|
||||
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: false },
|
||||
|
||||
// Apps
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
|
||||
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
|
||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
|
||||
{ id: 'debug', label: 'Debug', icon: Bug, group: 'advanced', implemented: false },
|
||||
];
|
||||
|
||||
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
account: AccountSettings,
|
||||
notifications: NotificationSettings,
|
||||
reading: ReadingSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
about_data: AboutDataSettings,
|
||||
updates: UpdatesSettings,
|
||||
};
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
onBack?: () => void;
|
||||
onTabSelect?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) return;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
setSelectedTab(null);
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectedTab]);
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{Component ? <Component /> : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Settings size={24} color={colors.primary} />
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>B</Text>
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => handleTabPress(tab)}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.tabItem,
|
||||
disabled && styles.tabItemDisabled,
|
||||
!disabled && pressed && styles.tabItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>Experimental</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>Bulwark User</Text>
|
||||
<Text style={styles.profileEmail}>user@example.com</Text>
|
||||
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<SettingItem
|
||||
icon={<User size={20} color={colors.primary} />}
|
||||
title="Account Settings"
|
||||
subtitle="Manage your account details"
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Shield size={20} color={colors.primary} />}
|
||||
title="Privacy & Security"
|
||||
subtitle="Passwords, encryption, trusted senders"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Preferences</Text>
|
||||
<SettingItem
|
||||
icon={<Palette size={20} color={colors.primary} />}
|
||||
title="Dark Mode"
|
||||
rightElement={<Switch value={darkMode} onValueChange={setDarkMode} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Bell size={20} color={colors.primary} />}
|
||||
title="Notifications"
|
||||
rightElement={<Switch value={notifications} onValueChange={setNotifications} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
|
||||
<Text style={styles.footerText}>Built with Expo + React Native</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
margin: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary,
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
|
||||
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
|
||||
section: { marginTop: 8 },
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
settingRow: {
|
||||
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: { marginRight: 14 },
|
||||
settingContent: { flex: 1 },
|
||||
settingTitle: { fontSize: 16, color: colors.text },
|
||||
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
footer: {
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 4,
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
footerText: { fontSize: 12, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
User, Mail, CalendarDays, Shield, Bell, Palette, Globe, Lock,
|
||||
ChevronRight, LogOut, Moon, Server, Key, Fingerprint, Info, HelpCircle
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
interface SettingItem {
|
||||
id: string;
|
||||
icon: any;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
type: 'link' | 'toggle';
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@bulwark.mail',
|
||||
plan: 'Pro',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; items: SettingItem[] }[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{ id: 'profile', icon: User, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Profile', detail: PROFILE.email, type: 'link' },
|
||||
{ id: 'identities', icon: Mail, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Identities & Signatures', type: 'link' },
|
||||
{ id: 'security', icon: Lock, iconColor: colors.error, iconBg: colors.errorBg, label: 'Security', detail: '2FA enabled', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
items: [
|
||||
{ id: 'filters', icon: Mail, iconColor: colors.calendar.teal, iconBg: colors.calendar.tealBg, label: 'Filters & Rules', type: 'link' },
|
||||
{ id: 'smime', icon: Shield, iconColor: colors.calendar.indigo, iconBg: colors.calendar.indigoBg, label: 'S/MIME Encryption', type: 'link' },
|
||||
{ id: 'vacation', icon: CalendarDays, iconColor: colors.calendar.orange, iconBg: colors.warningBg, label: 'Vacation Responder', detail: 'Off', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
items: [
|
||||
{ id: 'theme', icon: Palette, iconColor: colors.calendar.pink, iconBg: colors.calendar.pinkBg, label: 'Theme', detail: 'System', type: 'link' },
|
||||
{ id: 'darkMode', icon: Moon, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Dark Mode', type: 'toggle', value: false },
|
||||
{ id: 'language', icon: Globe, iconColor: colors.calendar.blue, iconBg: colors.primaryBg, label: 'Language', detail: 'English', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
items: [
|
||||
{ id: 'pushNotif', icon: Bell, iconColor: colors.calendar.green, iconBg: colors.successBg, label: 'Push Notifications', type: 'toggle', value: true },
|
||||
{ id: 'emailNotif', icon: Mail, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Email Notifications', type: 'toggle', value: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
items: [
|
||||
{ id: 'server', icon: Server, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Server Settings', type: 'link' },
|
||||
{ id: 'privacy', icon: Fingerprint, iconColor: colors.textSecondary, iconBg: colors.surface, label: 'Privacy', type: 'link' },
|
||||
{ id: 'about', icon: Info, iconColor: colors.textMuted, iconBg: colors.surface, label: 'About', detail: 'v1.0.0', type: 'link' },
|
||||
{ id: 'help', icon: HelpCircle, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Help & Support', type: 'link' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SettingRow({ item }: { item: SettingItem }) {
|
||||
const Icon = item.icon;
|
||||
const [toggled, setToggled] = React.useState(item.value ?? false);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]}
|
||||
>
|
||||
<View style={[styles.settingIcon, { backgroundColor: item.iconBg }]}>
|
||||
<Icon size={18} color={item.iconColor} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingLabel}>{item.label}</Text>
|
||||
{item.detail && item.type === 'link' && (
|
||||
<Text style={styles.settingDetail}>{item.detail}</Text>
|
||||
)}
|
||||
</View>
|
||||
{item.type === 'link' && (
|
||||
<ChevronRight size={18} color={colors.textMuted} />
|
||||
)}
|
||||
{item.type === 'toggle' && (
|
||||
<Switch
|
||||
value={toggled}
|
||||
onValueChange={setToggled}
|
||||
trackColor={{ false: colors.surface, true: colors.primary }}
|
||||
thumbColor={colors.background}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout }: SettingsScreenProps) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Profile card */}
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>JD</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>{PROFILE.name}</Text>
|
||||
<Text style={styles.profileEmail}>{PROFILE.email}</Text>
|
||||
</View>
|
||||
<View style={styles.planBadge}>
|
||||
<Text style={styles.planBadgeText}>{PROFILE.plan}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Settings sections */}
|
||||
{SECTIONS.map(section => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.sectionCard}>
|
||||
{section.items.map((item, idx) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{idx > 0 && <View style={styles.sectionSeparator} />}
|
||||
<SettingRow item={item} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Sign out */}
|
||||
<Pressable style={styles.signOutBtn} onPress={onLogout}>
|
||||
<LogOut size={18} color={colors.error} />
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Version info */}
|
||||
<Text style={styles.versionText}>Bulwark Mobile v0.0.1</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
scrollContent: { paddingBottom: 40 },
|
||||
|
||||
// Profile card
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: spacing.lg,
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.xl,
|
||||
gap: spacing.md,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: componentSizes.avatarLg, height: componentSizes.avatarLg,
|
||||
borderRadius: componentSizes.avatarLg / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
profileAvatarText: { ...typography.h3, color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { ...typography.bodyMedium, color: colors.text },
|
||||
profileEmail: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
planBadge: {
|
||||
backgroundColor: colors.primaryBg,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
planBadgeText: { ...typography.small, color: colors.primary },
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
sectionCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderLight,
|
||||
marginLeft: 56,
|
||||
},
|
||||
|
||||
// Setting row
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: {
|
||||
width: 32, height: 32,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
settingContent: { flex: 1 },
|
||||
settingLabel: { ...typography.body, color: colors.text },
|
||||
settingDetail: { ...typography.small, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Sign out
|
||||
signOutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
signOutText: { ...typography.bodyMedium, color: colors.error },
|
||||
|
||||
// Version
|
||||
versionText: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -1,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,
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user